detached 256407f

fix(theme): retain fallback chrome surfaces

commit
256407fcdb4090cc15313458b5111846d3ececae
author
brice · · 2026-07-18 09:32 UTC
parent
db2c447
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +254 -29
M internal/usecase/daemon/palette_test.go +68 -0
@@ -13,10 +13,78 @@ import (
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1414 "github.com/bnema/vev/internal/usecase/command"
1515 "github.com/bnema/vev/internal/usecase/palette"
16+ themeui "github.com/bnema/vev/internal/usecase/theme"
1617 "github.com/bnema/vev/internal/usecase/ui"
1718 "github.com/bnema/vev/pkg/renderer"
1819 )
1920
21+func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
22+ paletteColors := [16]renderer.RGB{}
23+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
24+ paletteColors[10] = paletteColors[2]
25+ accentTheme := themeui.Theme{
26+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
27+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
28+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
29+ }
30+ indexedTheme := accentTheme
31+ indexedTheme.TrueColor = false
32+ neutralTheme := accentTheme
33+ neutralTheme.PaletteKnown = 0
34+ neutralTheme.SchemeKnown = false
35+
36+ defaults := domain.Defaults()
37+ paletteOff := defaults
38+ paletteOff.ThemePalette = false
39+ forcedDark := defaults
40+ forcedDark.Theme = domain.ThemeDark
41+ forcedLight := defaults
42+ forcedLight.Theme = domain.ThemeLight
43+ tests := []struct {
44+ name string
45+ raw themeui.Theme
46+ config domain.Config
47+ indexed bool
48+ }{
49+ {name: "truecolor accent", raw: accentTheme, config: defaults},
50+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
51+ {name: "palette off", raw: accentTheme, config: paletteOff},
52+ {name: "forced dark", raw: accentTheme, config: forcedDark},
53+ {name: "forced light", raw: accentTheme, config: forcedLight},
54+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ d := newTestDaemon(t, nil, stubClock{})
60+ d.ApplyConfig(tt.config)
61+ applied := d.resolveAppliedTheme(tt.raw)
62+ model := palette.New(palette.CommandResults([]command.Command{
63+ {Code: "ONE", Name: "One", Desc: "first description"},
64+ {Code: "TWO", Name: "Two", Desc: "second description"},
65+ }))
66+ model.Down()
67+ state := capturedRenderState{
68+ theme: applied.Raw,
69+ styles: applied.Resolved.Styles,
70+ layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
71+ }
72+ snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model}
73+
74+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
75+
76+ description := state.overlays.palette.inner.At(4, 1).Style
77+ require.Equal(t, state.styles.SurfaceInactive.Background, description.Background)
78+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
79+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
80+ if tt.indexed {
81+ require.Equal(t, 2, description.Foreground)
82+ require.False(t, description.HasBackgroundRGB)
83+ }
84+ })
85+ }
86+}
87+
2088 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
2189 p1, release1 := newBlockingPTY(t)
2290 p2, release2 := newBlockingPTY(t)
@@ -13,10 +13,78 @@ import (
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/usecase/command"14 "github.com/bnema/vev/internal/usecase/command"
15 "github.com/bnema/vev/internal/usecase/palette"15 "github.com/bnema/vev/internal/usecase/palette"
16+ themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/internal/usecase/ui"17 "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
18 )19 )
19 20
21+func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
22+ paletteColors := [16]renderer.RGB{}
23+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
24+ paletteColors[10] = paletteColors[2]
25+ accentTheme := themeui.Theme{
26+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
27+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
28+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
29+ }
30+ indexedTheme := accentTheme
31+ indexedTheme.TrueColor = false
32+ neutralTheme := accentTheme
33+ neutralTheme.PaletteKnown = 0
34+ neutralTheme.SchemeKnown = false
35+
36+ defaults := domain.Defaults()
37+ paletteOff := defaults
38+ paletteOff.ThemePalette = false
39+ forcedDark := defaults
40+ forcedDark.Theme = domain.ThemeDark
41+ forcedLight := defaults
42+ forcedLight.Theme = domain.ThemeLight
43+ tests := []struct {
44+ name string
45+ raw themeui.Theme
46+ config domain.Config
47+ indexed bool
48+ }{
49+ {name: "truecolor accent", raw: accentTheme, config: defaults},
50+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
51+ {name: "palette off", raw: accentTheme, config: paletteOff},
52+ {name: "forced dark", raw: accentTheme, config: forcedDark},
53+ {name: "forced light", raw: accentTheme, config: forcedLight},
54+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ d := newTestDaemon(t, nil, stubClock{})
60+ d.ApplyConfig(tt.config)
61+ applied := d.resolveAppliedTheme(tt.raw)
62+ model := palette.New(palette.CommandResults([]command.Command{
63+ {Code: "ONE", Name: "One", Desc: "first description"},
64+ {Code: "TWO", Name: "Two", Desc: "second description"},
65+ }))
66+ model.Down()
67+ state := capturedRenderState{
68+ theme: applied.Raw,
69+ styles: applied.Resolved.Styles,
70+ layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
71+ }
72+ snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model}
73+
74+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
75+
76+ description := state.overlays.palette.inner.At(4, 1).Style
77+ require.Equal(t, state.styles.SurfaceInactive.Background, description.Background)
78+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
79+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
80+ if tt.indexed {
81+ require.Equal(t, 2, description.Foreground)
82+ require.False(t, description.HasBackgroundRGB)
83+ }
84+ })
85+ }
86+}
87+
20 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {88 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
21 p1, release1 := newBlockingPTY(t)89 p1, release1 := newBlockingPTY(t)
22 p2, release2 := newBlockingPTY(t)90 p2, release2 := newBlockingPTY(t)
M internal/usecase/daemon/picker_test.go +60 -12
@@ -642,36 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
642642 require.True(t, subscribed)
643643 }
644644
645-func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) {
646- for _, tt := range []struct {
647- name string
648- theme themeui.Theme
645+func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) {
646+ palette := [16]renderer.RGB{}
647+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
648+ palette[10] = palette[2]
649+ accentTheme := themeui.Theme{
650+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
651+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
652+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
653+ }
654+ indexedTheme := accentTheme
655+ indexedTheme.TrueColor = false
656+ neutralTheme := accentTheme
657+ neutralTheme.PaletteKnown = 0
658+ neutralTheme.SchemeKnown = false
659+
660+ defaults := domain.Defaults()
661+ paletteOff := defaults
662+ paletteOff.ThemePalette = false
663+ forcedDark := defaults
664+ forcedDark.Theme = domain.ThemeDark
665+ forcedLight := defaults
666+ forcedLight.Theme = domain.ThemeLight
667+ tests := []struct {
668+ name string
669+ raw themeui.Theme
670+ config domain.Config
671+ indexed bool
649672 }{
650- {name: "default terminal", theme: themeui.Theme{}},
651- {name: "truecolor terminal", theme: themeui.BuiltinDark},
652- } {
673+ {name: "truecolor accent", raw: accentTheme, config: defaults},
674+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
675+ {name: "palette off", raw: accentTheme, config: paletteOff},
676+ {name: "forced dark", raw: accentTheme, config: forcedDark},
677+ {name: "forced light", raw: accentTheme, config: forcedLight},
678+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
679+ }
680+
681+ for _, tt := range tests {
653682 t.Run(tt.name, func(t *testing.T) {
683+ d := newTestDaemon(t, nil, stubClock{})
684+ d.ApplyConfig(tt.config)
685+ applied := d.resolveAppliedTheme(tt.raw)
654686 state := capturedRenderState{
655- theme: tt.theme,
656- styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
687+ theme: applied.Raw,
688+ styles: applied.Resolved.Styles,
657689 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
658690 }
659691 snap := &overlayRenderSnapshot{
660692 pickerActive: true,
661- pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0),
693+ pickerModel: picker.New([]picker.SessionView{
694+ {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
695+ {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
696+ }, "selected", 0),
662697 }
663698
664699 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
665700
701+ require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background)
702+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB)
703+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB)
704+ require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background)
705+ require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB)
706+ require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB)
707+ if tt.indexed {
708+ require.Equal(t, 2, state.styles.PickerDescription.Foreground)
709+ require.Equal(t, 2, state.styles.PickerSeparator.Foreground)
710+ require.False(t, state.styles.PickerDescription.HasBackgroundRGB)
711+ require.False(t, state.styles.PickerSeparator.HasBackgroundRGB)
712+ }
713+
666714 inner := state.overlays.picker.inner
667715 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
668716 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
669- want := state.styles.PickerSeparator
717+ require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface")
670718 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
671719 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
672720 cell := inner.At(x, y)
673721 require.Equal(t, '│', cell.Rune)
674- require.True(t, cell.Style.Equal(want))
722+ require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface")
675723 }
676724 }
677725 })
@@ -642,36 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
642 require.True(t, subscribed)642 require.True(t, subscribed)
643 }643 }
644 644
645-func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) {645+func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) {
646- for _, tt := range []struct {646+ palette := [16]renderer.RGB{}
647- name string647+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
648- theme themeui.Theme648+ palette[10] = palette[2]
649+ accentTheme := themeui.Theme{
650+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
651+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
652+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
653+ }
654+ indexedTheme := accentTheme
655+ indexedTheme.TrueColor = false
656+ neutralTheme := accentTheme
657+ neutralTheme.PaletteKnown = 0
658+ neutralTheme.SchemeKnown = false
659+
660+ defaults := domain.Defaults()
661+ paletteOff := defaults
662+ paletteOff.ThemePalette = false
663+ forcedDark := defaults
664+ forcedDark.Theme = domain.ThemeDark
665+ forcedLight := defaults
666+ forcedLight.Theme = domain.ThemeLight
667+ tests := []struct {
668+ name string
669+ raw themeui.Theme
670+ config domain.Config
671+ indexed bool
649 }{672 }{
650- {name: "default terminal", theme: themeui.Theme{}},673+ {name: "truecolor accent", raw: accentTheme, config: defaults},
651- {name: "truecolor terminal", theme: themeui.BuiltinDark},674+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
652- } {675+ {name: "palette off", raw: accentTheme, config: paletteOff},
676+ {name: "forced dark", raw: accentTheme, config: forcedDark},
677+ {name: "forced light", raw: accentTheme, config: forcedLight},
678+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
679+ }
680+
681+ for _, tt := range tests {
653 t.Run(tt.name, func(t *testing.T) {682 t.Run(tt.name, func(t *testing.T) {
683+ d := newTestDaemon(t, nil, stubClock{})
684+ d.ApplyConfig(tt.config)
685+ applied := d.resolveAppliedTheme(tt.raw)
654 state := capturedRenderState{686 state := capturedRenderState{
655- theme: tt.theme,687+ theme: applied.Raw,
656- styles: themeui.Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,688+ styles: applied.Resolved.Styles,
657 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},689 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
658 }690 }
659 snap := &overlayRenderSnapshot{691 snap := &overlayRenderSnapshot{
660 pickerActive: true,692 pickerActive: true,
661- pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0),693+ pickerModel: picker.New([]picker.SessionView{
694+ {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
695+ {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
696+ }, "selected", 0),
662 }697 }
663 698
664 captureOverlayLayers(&state, snap, domain.PaletteConfig{})699 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
665 700
701+ require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background)
702+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB)
703+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB)
704+ require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background)
705+ require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB)
706+ require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB)
707+ if tt.indexed {
708+ require.Equal(t, 2, state.styles.PickerDescription.Foreground)
709+ require.Equal(t, 2, state.styles.PickerSeparator.Foreground)
710+ require.False(t, state.styles.PickerDescription.HasBackgroundRGB)
711+ require.False(t, state.styles.PickerSeparator.HasBackgroundRGB)
712+ }
713+
666 inner := state.overlays.picker.inner714 inner := state.overlays.picker.inner
667 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})715 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
668 require.Equal(t, picker.LayoutHorizontal, layout.Mode)716 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
669- want := state.styles.PickerSeparator717+ require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface")
670 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {718 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
671 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {719 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
672 cell := inner.At(x, y)720 cell := inner.At(x, y)
673 require.Equal(t, '│', cell.Rune)721 require.Equal(t, '│', cell.Rune)
674- require.True(t, cell.Style.Equal(want))722+ require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface")
675 }723 }
676 }724 }
677 })725 })
M internal/usecase/palette/model_test.go +54 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/usecase/command"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
1011 "github.com/bnema/vev/pkg/renderer"
1112 "github.com/stretchr/testify/require"
1213 )
@@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
270271 }
271272 }
272273
274+func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
275+ paletteColors := [16]renderer.RGB{}
276+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
277+ paletteColors[10] = paletteColors[2]
278+ accentTheme := themeui.Theme{
279+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
280+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
281+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
282+ }
283+ indexedTheme := accentTheme
284+ indexedTheme.TrueColor = false
285+ paletteOffTheme := accentTheme
286+ paletteOffTheme.UsePalette = false
287+ neutralTheme := paletteOffTheme
288+ neutralTheme.PaletteKnown = 0
289+
290+ tests := []struct {
291+ name string
292+ theme themeui.Theme
293+ policy domain.ThemeAccent
294+ }{
295+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
296+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
297+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
298+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
299+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
300+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
301+ }
302+
303+ for _, tt := range tests {
304+ t.Run(tt.name, func(t *testing.T) {
305+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
306+ model := New(CommandResults([]command.Command{
307+ cmd("ONE", "One", "first description"),
308+ cmd("TWO", "Two", "second description"),
309+ }))
310+ model.Down()
311+ frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
312+ Base: styles.PickerBase, Row: styles.SurfaceInactive,
313+ Selection: styles.PickerSelection, Description: styles.PickerDescription,
314+ }})
315+ description := frame.At(4, 1).Style
316+ require.Equal(t, styles.SurfaceInactive.Background, description.Background)
317+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
318+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
319+ if tt.name == "indexed only" {
320+ require.Equal(t, 2, description.Foreground)
321+ require.False(t, description.HasBackgroundRGB)
322+ }
323+ })
324+ }
325+}
326+
273327 func TestModelCompleteSelected(t *testing.T) {
274328 jrs := cmd("JRS", "Jump", "Jump to recent session")
275329 jrs.Arguments = command.ArgumentsRequired
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
10 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 )13 )
@@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
270 }271 }
271 }272 }
272 273
274+func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
275+ paletteColors := [16]renderer.RGB{}
276+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
277+ paletteColors[10] = paletteColors[2]
278+ accentTheme := themeui.Theme{
279+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
280+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
281+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
282+ }
283+ indexedTheme := accentTheme
284+ indexedTheme.TrueColor = false
285+ paletteOffTheme := accentTheme
286+ paletteOffTheme.UsePalette = false
287+ neutralTheme := paletteOffTheme
288+ neutralTheme.PaletteKnown = 0
289+
290+ tests := []struct {
291+ name string
292+ theme themeui.Theme
293+ policy domain.ThemeAccent
294+ }{
295+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
296+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
297+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
298+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
299+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
300+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
301+ }
302+
303+ for _, tt := range tests {
304+ t.Run(tt.name, func(t *testing.T) {
305+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
306+ model := New(CommandResults([]command.Command{
307+ cmd("ONE", "One", "first description"),
308+ cmd("TWO", "Two", "second description"),
309+ }))
310+ model.Down()
311+ frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
312+ Base: styles.PickerBase, Row: styles.SurfaceInactive,
313+ Selection: styles.PickerSelection, Description: styles.PickerDescription,
314+ }})
315+ description := frame.At(4, 1).Style
316+ require.Equal(t, styles.SurfaceInactive.Background, description.Background)
317+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
318+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
319+ if tt.name == "indexed only" {
320+ require.Equal(t, 2, description.Foreground)
321+ require.False(t, description.HasBackgroundRGB)
322+ }
323+ })
324+ }
325+}
326+
273 func TestModelCompleteSelected(t *testing.T) {327 func TestModelCompleteSelected(t *testing.T) {
274 jrs := cmd("JRS", "Jump", "Jump to recent session")328 jrs := cmd("JRS", "Jump", "Jump to recent session")
275 jrs.Arguments = command.ArgumentsRequired329 jrs.Arguments = command.ArgumentsRequired
M internal/usecase/picker/model_test.go +48 -12
@@ -350,28 +350,64 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
350350 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
351351 }
352352
353-func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) {
353+func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) {
354354 palette := [16]renderer.RGB{}
355355 palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
356356 palette[10] = palette[2]
357- hostTheme := themeui.Theme{
357+ accentTheme := themeui.Theme{
358358 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
359359 HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
360360 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
361361 }
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")
362+ indexedTheme := accentTheme
363+ indexedTheme.TrueColor = false
364+ paletteOffTheme := accentTheme
365+ paletteOffTheme.UsePalette = false
366+ neutralTheme := accentTheme
367+ neutralTheme.UsePalette = false
368+ neutralTheme.PaletteKnown = 0
369+
370+ tests := []struct {
371+ name string
372+ theme themeui.Theme
373+ policy domain.ThemeAccent
374+ }{
375+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
376+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
377+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
378+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
379+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
380+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
381+ }
364382
365383 model := New([]SessionView{
366384 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
367385 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
368386 }, "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")
387+ for _, tt := range tests {
388+ t.Run(tt.name, func(t *testing.T) {
389+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
390+ require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background)
391+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB)
392+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB)
393+ require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background)
394+ require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB)
395+ require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB)
396+ if tt.name == "indexed only" {
397+ require.Equal(t, 2, styles.PickerDescription.Foreground)
398+ require.Equal(t, 2, styles.PickerSeparator.Foreground)
399+ require.False(t, styles.PickerDescription.HasBackgroundRGB)
400+ require.False(t, styles.PickerSeparator.HasBackgroundRGB)
401+ }
402+
403+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
404+ Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,
405+ Detail: styles.PickerDescription, Selection: styles.PickerSelection,
406+ SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
407+ Separator: styles.PickerSeparator,
408+ })
409+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")
410+ require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")
411+ })
412+ }
377413 }
@@ -350,28 +350,64 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
350 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")
351 }351 }
352 352
353-func TestAccentPickerDetailKeepsInactiveSurfaceAndListFiller(t *testing.T) {353+func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) {
354 palette := [16]renderer.RGB{}354 palette := [16]renderer.RGB{}
355 palette[2] = renderer.RGB{R: 10, G: 230, B: 120}355 palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
356 palette[10] = palette[2]356 palette[10] = palette[2]
357- hostTheme := themeui.Theme{357+ accentTheme := themeui.Theme{
358 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},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,359 HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
360 Palette: palette, PaletteKnown: 1<<2 | 1<<10,360 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
361 }361 }
362- styles := themeui.Resolve(hostTheme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles362+ indexedTheme := accentTheme
363- require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB, "description must retain the inactive row surface")363+ indexedTheme.TrueColor = false
364+ paletteOffTheme := accentTheme
365+ paletteOffTheme.UsePalette = false
366+ neutralTheme := accentTheme
367+ neutralTheme.UsePalette = false
368+ neutralTheme.PaletteKnown = 0
369+
370+ tests := []struct {
371+ name string
372+ theme themeui.Theme
373+ policy domain.ThemeAccent
374+ }{
375+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
376+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
377+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
378+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
379+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
380+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
381+ }
364 382
365 model := New([]SessionView{383 model := New([]SessionView{
366 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},384 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
367 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},385 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
368 }, "selected", 0)386 }, "selected", 0)
369- frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{387+ for _, tt := range tests {
370- Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,388+ t.Run(tt.name, func(t *testing.T) {
371- Detail: styles.PickerDescription, Selection: styles.PickerSelection,389+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
372- SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,390+ require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background)
373- Separator: styles.PickerSeparator,391+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB)
374- })392+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB)
375- require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")393+ require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background)
376- require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")394+ require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB)
395+ require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB)
396+ if tt.name == "indexed only" {
397+ require.Equal(t, 2, styles.PickerDescription.Foreground)
398+ require.Equal(t, 2, styles.PickerSeparator.Foreground)
399+ require.False(t, styles.PickerDescription.HasBackgroundRGB)
400+ require.False(t, styles.PickerSeparator.HasBackgroundRGB)
401+ }
402+
403+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
404+ Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,
405+ Detail: styles.PickerDescription, Selection: styles.PickerSelection,
406+ SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
407+ Separator: styles.PickerSeparator,
408+ })
409+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")
410+ require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")
411+ })
412+ }
377 }413 }
M internal/usecase/theme/phase3_review_test.go +3 -0
@@ -115,6 +115,9 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
115115 t.Run(tt.name, func(t *testing.T) {
116116 got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
117117 want := prePhase3LegacyAliases(tt.theme)
118+ // PickerSeparator is now a semantic role with the SurfaceBar
119+ // background; it was never a compatibility alias.
120+ delete(want, "PickerSeparator")
118121 for name, style := range want {
119122 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
120123 }
@@ -115,6 +115,9 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
115 t.Run(tt.name, func(t *testing.T) {115 t.Run(tt.name, func(t *testing.T) {
116 got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles116 got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
117 want := prePhase3LegacyAliases(tt.theme)117 want := prePhase3LegacyAliases(tt.theme)
118+ // PickerSeparator is now a semantic role with the SurfaceBar
119+ // background; it was never a compatibility alias.
120+ delete(want, "PickerSeparator")
118 for name, style := range want {121 for name, style := range want {
119 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)122 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
120 }123 }
M internal/usecase/theme/resolved_styles.go +21 -5
@@ -89,7 +89,7 @@ func indexedStyles(styles Styles, slot uint8) Styles {
8989 // blue fallback too. Keep the strict explicit index on each foreground
9090 // decoration without changing any neutral surface background.
9191 styles.Border = styles.BorderMuted
92- styles.PaletteDesc = styles.PickerDescription
92+ styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot)
9393 styles.TabTitle = styles.TabInactiveTitle
9494 styles.TabTitleActive = styles.TabActiveTitle
9595 styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
@@ -109,12 +109,18 @@ func neutralStyles(t Theme) Styles {
109109 status := renderer.DefaultStyle()
110110 accent := inverseStyle()
111111 border := renderer.DefaultStyle()
112- muted := renderer.DefaultStyle()
112+ inactiveDescription := status
113+ barDescription := status
113114 if usable(t) {
114115 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
115116 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
116117 border = neutralBorderStyle(t)
117- muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
118+ // Preserve the legacy neutral muted foreground while retaining the
119+ // semantic row/bar background beneath description and separator cells.
120+ inactiveDescription = status
121+ inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
122+ barDescription = status
123+ barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
118124 }
119125 styles := Styles{
120126 SurfaceBar: status,
@@ -132,13 +138,16 @@ func neutralStyles(t Theme) Styles {
132138
133139 PickerBase: status,
134140 PickerSelection: EmphasisStyle(accent, t),
135- PickerDescription: muted,
136- PickerSeparator: muted,
141+ PickerDescription: inactiveDescription,
142+ PickerSeparator: barDescription,
137143 PromptBase: status,
138144 CopyStatus: accent,
139145 SearchSelection: accent,
140146 }
141147 styles = withLegacyAliases(styles, t)
148+ // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
149+ // foreground-only bytes; rendered palette descriptions use PickerDescription.
150+ styles.PaletteDesc = legacyMutedText(t)
142151 // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;
143152 // MRU entries deliberately share the neutral recent surface.
144153 return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
@@ -164,6 +173,13 @@ func NeutralBorderStyle(t Theme) renderer.Style {
164173
165174 func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }
166175
176+func legacyMutedText(t Theme) renderer.Style {
177+ if !usable(t) {
178+ return renderer.DefaultStyle()
179+ }
180+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
181+}
182+
167183 func withLegacyAliases(styles Styles, t Theme) Styles {
168184 styles.StatusBar = styles.SurfaceBar
169185 styles.Accent = styles.SurfaceActive
@@ -89,7 +89,7 @@ func indexedStyles(styles Styles, slot uint8) Styles {
89 // blue fallback too. Keep the strict explicit index on each foreground89 // blue fallback too. Keep the strict explicit index on each foreground
90 // decoration without changing any neutral surface background.90 // decoration without changing any neutral surface background.
91 styles.Border = styles.BorderMuted91 styles.Border = styles.BorderMuted
92- styles.PaletteDesc = styles.PickerDescription92+ styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot)
93 styles.TabTitle = styles.TabInactiveTitle93 styles.TabTitle = styles.TabInactiveTitle
94 styles.TabTitleActive = styles.TabActiveTitle94 styles.TabTitleActive = styles.TabActiveTitle
95 styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)95 styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
@@ -109,12 +109,18 @@ func neutralStyles(t Theme) Styles {
109 status := renderer.DefaultStyle()109 status := renderer.DefaultStyle()
110 accent := inverseStyle()110 accent := inverseStyle()
111 border := renderer.DefaultStyle()111 border := renderer.DefaultStyle()
112- muted := renderer.DefaultStyle()112+ inactiveDescription := status
113+ barDescription := status
113 if usable(t) {114 if usable(t) {
114 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))115 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
115 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))116 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
116 border = neutralBorderStyle(t)117 border = neutralBorderStyle(t)
117- muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))118+ // Preserve the legacy neutral muted foreground while retaining the
119+ // semantic row/bar background beneath description and separator cells.
120+ inactiveDescription = status
121+ inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
122+ barDescription = status
123+ barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
118 }124 }
119 styles := Styles{125 styles := Styles{
120 SurfaceBar: status,126 SurfaceBar: status,
@@ -132,13 +138,16 @@ func neutralStyles(t Theme) Styles {
132 138
133 PickerBase: status,139 PickerBase: status,
134 PickerSelection: EmphasisStyle(accent, t),140 PickerSelection: EmphasisStyle(accent, t),
135- PickerDescription: muted,141+ PickerDescription: inactiveDescription,
136- PickerSeparator: muted,142+ PickerSeparator: barDescription,
137 PromptBase: status,143 PromptBase: status,
138 CopyStatus: accent,144 CopyStatus: accent,
139 SearchSelection: accent,145 SearchSelection: accent,
140 }146 }
141 styles = withLegacyAliases(styles, t)147 styles = withLegacyAliases(styles, t)
148+ // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
149+ // foreground-only bytes; rendered palette descriptions use PickerDescription.
150+ styles.PaletteDesc = legacyMutedText(t)
142 // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;151 // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;
143 // MRU entries deliberately share the neutral recent surface.152 // MRU entries deliberately share the neutral recent surface.
144 return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})153 return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
@@ -164,6 +173,13 @@ func NeutralBorderStyle(t Theme) renderer.Style {
164 173
165 func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }174 func neutralBorderStyle(t Theme) renderer.Style { return NeutralBorderStyle(t) }
166 175
176+func legacyMutedText(t Theme) renderer.Style {
177+ if !usable(t) {
178+ return renderer.DefaultStyle()
179+ }
180+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
181+}
182+
167 func withLegacyAliases(styles Styles, t Theme) Styles {183 func withLegacyAliases(styles Styles, t Theme) Styles {
168 styles.StatusBar = styles.SurfaceBar184 styles.StatusBar = styles.SurfaceBar
169 styles.Accent = styles.SurfaceActive185 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