detached db2c447

fix(theme): preserve neutral chrome surfaces

commit
db2c4474c0c9c326857c142ca1c3ecc2126277b5
author
brice · · 2026-07-18 09:12 UTC
parent
adf123a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +84 -9
M internal/usecase/daemon/copymode.go +2 -2
@@ -416,7 +416,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
416416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417417 return frame, nil
418418 }
419- copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
419+ copyFrame := mode.Render(styles.SurfaceBar, styles.Selection)
420420 bodyRows := max(copyFrame.Height-1, 0)
421421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
422422 dstX := max(target.X, 0)
@@ -429,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
429429 }
430430 statusY := frame.Height - 1
431431 for x := range frame.Row(statusY) {
432- frame.Row(statusY)[x] = renderer.BlankCell()
432+ frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}
433433 }
434434 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
435435 return frame, []renderer.Damage{renderer.FullRedraw()}
@@ -416,7 +416,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417 return frame, nil417 return frame, nil
418 }418 }
419- copyFrame := mode.Render(styles.CopyStatus, styles.Selection)419+ copyFrame := mode.Render(styles.SurfaceBar, styles.Selection)
420 bodyRows := max(copyFrame.Height-1, 0)420 bodyRows := max(copyFrame.Height-1, 0)
421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
422 dstX := max(target.X, 0)422 dstX := max(target.X, 0)
@@ -429,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
429 }429 }
430 statusY := frame.Height - 1430 statusY := frame.Height - 1
431 for x := range frame.Row(statusY) {431 for x := range frame.Row(statusY) {
432- frame.Row(statusY)[x] = renderer.BlankCell()432+ frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}
433 }433 }
434 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])434 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
435 return frame, []renderer.Damage{renderer.FullRedraw()}435 return frame, []renderer.Damage{renderer.FullRedraw()}
M internal/usecase/daemon/copymode_test.go +16 -0
@@ -880,3 +880,19 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
880880 want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
881881 require.Equal(t, want, string(msg.Data))
882882 }
883+
884+func TestComposeCopyClientFrameFillsFullStatusRowForSplitPane(t *testing.T) {
885+ base := renderer.NewFrame(20, 8)
886+ pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
887+ pane.screen.Write([]byte("copy"))
888+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators))
889+ styles := resolveStyles(nil)
890+ styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3}
891+
892+ frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles)
893+
894+ for x, cell := range frame.Row(frame.Height - 1) {
895+ require.Truef(t, cell.Style.Equal(styles.SurfaceBar), "status cell %d must retain the full-width SurfaceBar role", x)
896+ }
897+ require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]")
898+}
@@ -880,3 +880,19 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
880 want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"880 want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
881 require.Equal(t, want, string(msg.Data))881 require.Equal(t, want, string(msg.Data))
882 }882 }
883+
884+func TestComposeCopyClientFrameFillsFullStatusRowForSplitPane(t *testing.T) {
885+ base := renderer.NewFrame(20, 8)
886+ pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
887+ pane.screen.Write([]byte("copy"))
888+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators))
889+ styles := resolveStyles(nil)
890+ styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3}
891+
892+ frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles)
893+
894+ for x, cell := range frame.Row(frame.Height - 1) {
895+ require.Truef(t, cell.Style.Equal(styles.SurfaceBar), "status cell %d must retain the full-width SurfaceBar role", x)
896+ }
897+ require.Contains(t, rowText(frame.Row(frame.Height-1)), "[SCROLL]")
898+}
M internal/usecase/daemon/render_pipeline.go +6 -5
@@ -89,12 +89,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
8989 }
9090 state.styles = styles
9191 defaultDimmer := themeui.NewDimmer(state.theme)
92+ neutralBorder := themeui.NeutralBorderStyle(state.theme)
9293 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
9394 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
9495 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
9596 content := domain.Rect{Y: 1, Width: width, Height: rows}
9697 if state.layout.valid && state.layout.root != nil {
97- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
98+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder))
9899 }
99100
100101 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
@@ -107,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
107108 for _, pane := range state.panes {
108109 pl := offsetPlacement(pane.placement, 0, 1)
109110 if pl.TitleBar.Height > 0 {
110- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
111+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
111112 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
112113 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
113114 }
@@ -168,12 +169,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
168169 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
169170 }
170171
171-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) {
172- style := styles.Border
172+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
173+ style := neutralBorder
173174 if focused {
174175 style = styles.StatusBar
175176 } else {
176- style = dimmer.Dim(style)
177+ style = dimmer.Dim(neutralBorder)
177178 }
178179 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
179180 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -89,12 +89,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
89 }89 }
90 state.styles = styles90 state.styles = styles
91 defaultDimmer := themeui.NewDimmer(state.theme)91 defaultDimmer := themeui.NewDimmer(state.theme)
92+ neutralBorder := themeui.NeutralBorderStyle(state.theme)
92 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))93 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
93 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)94 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
94 drawStatusBarState(frame.Row(rows+1), state.bars, styles)95 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
95 content := domain.Rect{Y: 1, Width: width, Height: rows}96 content := domain.Rect{Y: 1, Width: width, Height: rows}
96 if state.layout.valid && state.layout.root != nil {97 if state.layout.valid && state.layout.root != nil {
97- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))98+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder))
98 }99 }
99 100
100 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible101 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
@@ -107,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
107 for _, pane := range state.panes {108 for _, pane := range state.panes {
108 pl := offsetPlacement(pane.placement, 0, 1)109 pl := offsetPlacement(pane.placement, 0, 1)
109 if pl.TitleBar.Height > 0 {110 if pl.TitleBar.Height > 0 {
110- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)111+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
111 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {112 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
112 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})113 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
113 }114 }
@@ -168,12 +169,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
168 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}169 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
169 }170 }
170 171
171-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) {172+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
172- style := styles.Border173+ style := neutralBorder
173 if focused {174 if focused {
174 style = styles.StatusBar175 style = styles.StatusBar
175 } else {176 } else {
176- style = dimmer.Dim(style)177+ style = dimmer.Dim(neutralBorder)
177 }178 }
178 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {179 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
179 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})180 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
M internal/usecase/daemon/theme_cache_test.go +26 -0
@@ -76,3 +76,29 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
7676 require.Equal(t, cell, out.frame.At(0, 1))
7777 require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
7878 }
79+
80+func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
81+ raw := cacheTheme()
82+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
83+ neutral := renderer.DefaultStyle()
84+ neutral.HasForegroundRGB = true
85+ neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)
86+ want := themeui.NewDimmer(raw).Dim(neutral)
87+ accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted)
88+
89+ split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)
90+ split.styles = resolved.Styles
91+ splitOut := composeFrame(split, composeCacheInput{})
92+ require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged")
93+ require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged")
94+ require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input")
95+ require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer")
96+
97+ titles := cachedStackTitleState("inactive", 1, true)
98+ titles.theme, titles.styles = raw, resolved.Styles
99+ titleOut := composeFrame(titles, composeCacheInput{})
100+ require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged")
101+ require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input")
102+ require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer")
103+ require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged")
104+}
@@ -76,3 +76,29 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
76 require.Equal(t, cell, out.frame.At(0, 1))76 require.Equal(t, cell, out.frame.At(0, 1))
77 require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)77 require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
78 }78 }
79+
80+func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
81+ raw := cacheTheme()
82+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
83+ neutral := renderer.DefaultStyle()
84+ neutral.HasForegroundRGB = true
85+ neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)
86+ want := themeui.NewDimmer(raw).Dim(neutral)
87+ accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted)
88+
89+ split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)
90+ split.styles = resolved.Styles
91+ splitOut := composeFrame(split, composeCacheInput{})
92+ require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged")
93+ require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged")
94+ require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input")
95+ require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer")
96+
97+ titles := cachedStackTitleState("inactive", 1, true)
98+ titles.theme, titles.styles = raw, resolved.Styles
99+ titleOut := composeFrame(titles, composeCacheInput{})
100+ require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged")
101+ require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input")
102+ require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer")
103+ require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged")
104+}
M internal/usecase/picker/model_test.go +27 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
910 "github.com/stretchr/testify/require"
@@ -348,3 +349,29 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
348349 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
349350 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
350351 }
352+
353+func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) {
354+ palette := [16]renderer.RGB{}
355+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
356+ palette[10] = palette[2]
357+ hostTheme := themeui.Theme{
358+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
359+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
360+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
361+ }
362+ styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
363+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface")
364+
365+ model := New([]SessionView{
366+ {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
367+ {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
368+ }, "selected", 0)
369+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
370+ Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,
371+ Detail: styles.PickerDescription, Selection: styles.PickerSelection,
372+ SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
373+ Separator: styles.PickerSeparator,
374+ })
375+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")
376+ require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")
377+}
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
7 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
8 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -348,3 +349,29 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
348 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")349 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
349 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")350 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
350 }351 }
352+
353+func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) {
354+ palette := [16]renderer.RGB{}
355+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
356+ palette[10] = palette[2]
357+ hostTheme := themeui.Theme{
358+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
359+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
360+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
361+ }
362+ styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
363+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface")
364+
365+ model := New([]SessionView{
366+ {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
367+ {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
368+ }, "selected", 0)
369+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
370+ Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,
371+ Detail: styles.PickerDescription, Selection: styles.PickerSelection,
372+ SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
373+ Separator: styles.PickerSeparator,
374+ })
375+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")
376+ require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")
377+}
M internal/usecase/theme/resolved_styles.go +7 -2
@@ -36,6 +36,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
3636
3737 inactiveTitle := secondarySurface(ramp.SurfaceInactive)
3838 activeTitle := secondarySurface(ramp.SurfaceActive)
39+ inactiveDescription := secondarySurface(ramp.SurfaceInactive)
3940 barDescription := secondarySurface(ramp.SurfaceBar)
4041 styles := Styles{
4142 SurfaceBar: ramp.SurfaceBar,
@@ -53,7 +54,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
5354
5455 PickerBase: ramp.SurfaceBar,
5556 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
56- PickerDescription: barDescription,
57+ PickerDescription: inactiveDescription,
5758 // Separators are secondary text on the picker base surface, not
5859 // non-text borders; they therefore require the normal 4.5:1 contrast.
5960 PickerSeparator: barDescription,
@@ -152,13 +153,17 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles {
152153 return styles
153154 }
154155
155-func neutralBorderStyle(t Theme) renderer.Style {
156+// NeutralBorderStyle is the existing non-accent structural border input for
157+// dimmed pane chrome. It intentionally does not consume a resolved accent.
158+func NeutralBorderStyle(t Theme) renderer.Style {
156159 if !usable(t) {
157160 return renderer.DefaultStyle()
158161 }
159162 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
160163 }
161164
165+func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }
166+
162167 func withLegacyAliases(styles Styles, t Theme) Styles {
163168 styles.StatusBar = styles.SurfaceBar
164169 styles.Accent = styles.SurfaceActive
@@ -36,6 +36,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
36 36
37 inactiveTitle := secondarySurface(ramp.SurfaceInactive)37 inactiveTitle := secondarySurface(ramp.SurfaceInactive)
38 activeTitle := secondarySurface(ramp.SurfaceActive)38 activeTitle := secondarySurface(ramp.SurfaceActive)
39+ inactiveDescription := secondarySurface(ramp.SurfaceInactive)
39 barDescription := secondarySurface(ramp.SurfaceBar)40 barDescription := secondarySurface(ramp.SurfaceBar)
40 styles := Styles{41 styles := Styles{
41 SurfaceBar: ramp.SurfaceBar,42 SurfaceBar: ramp.SurfaceBar,
@@ -53,7 +54,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
53 54
54 PickerBase: ramp.SurfaceBar,55 PickerBase: ramp.SurfaceBar,
55 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),56 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
56- PickerDescription: barDescription,57+ PickerDescription: inactiveDescription,
57 // Separators are secondary text on the picker base surface, not58 // Separators are secondary text on the picker base surface, not
58 // non-text borders; they therefore require the normal 4.5:1 contrast.59 // non-text borders; they therefore require the normal 4.5:1 contrast.
59 PickerSeparator: barDescription,60 PickerSeparator: barDescription,
@@ -152,13 +153,17 @@ func withMRUStyles(styles Styles, ramp Ramp) Styles {
152 return styles153 return styles
153 }154 }
154 155
155-func neutralBorderStyle(t Theme) renderer.Style {156+// NeutralBorderStyle is the existing non-accent structural border input for
157+// dimmed pane chrome. It intentionally does not consume a resolved accent.
158+func NeutralBorderStyle(t Theme) renderer.Style {
156 if !usable(t) {159 if !usable(t) {
157 return renderer.DefaultStyle()160 return renderer.DefaultStyle()
158 }161 }
159 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))162 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
160 }163 }
161 164
165+func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }
166+
162 func withLegacyAliases(styles Styles, t Theme) Styles {167 func withLegacyAliases(styles Styles, t Theme) Styles {
163 styles.StatusBar = styles.SurfaceBar168 styles.StatusBar = styles.SurfaceBar
164 styles.Accent = styles.SurfaceActive169 styles.Accent = styles.SurfaceActive

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close