fix(theme): keep the active tab title readable with the indexed accent fallback
When a terminal reports default colors but no palette, the scheme blue slot recolored the active title on the full-strength neutral surface (about 1.7:1 on light themes). The title keeps its RGB secondary text there.
- commit
- 4604c719b00d5c493ce5f906b15831644964693f
- author
- brice · · 2026-10-08 05:38 UTC
- committer
- brice · · 2026-10-08 05:46 UTC
- parent
- f0478a2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +36 -1
M internal/usecase/theme/neutral_session_test.go +31 -0
| @@ -4,9 +4,40 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | // TestIndexedFallbackKeepsActiveTitleReadable covers a terminal that reports | |
| 12 | + | // its default colors but no palette: the scheme blue fallback must not recolor | |
| 13 | + | // the title drawn on the full-strength active surface. | |
| 14 | + | func TestIndexedFallbackKeepsActiveTitleReadable(t *testing.T) { | |
| 15 | + | black := renderer.RGB{} | |
| 16 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |
| 17 | + | for _, tt := range []struct { | |
| 18 | + | name string | |
| 19 | + | foreground, background renderer.RGB | |
| 20 | + | light bool | |
| 21 | + | }{ | |
| 22 | + | {name: "dark", foreground: white, background: black}, | |
| 23 | + | {name: "light", foreground: black, background: white, light: true}, | |
| 24 | + | } { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | theme := Theme{ | |
| 27 | + | Known: true, SchemeKnown: true, Light: tt.light, UsePalette: true, | |
| 28 | + | Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true, | |
| 29 | + | } | |
| 30 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 31 | + | require.True(t, resolved.Accent.IndexedOnly) | |
| 32 | + | ||
| 33 | + | want := MutedVariantStyle(resolved.Styles.SurfaceActive, theme) | |
| 34 | + | require.Equal(t, want, resolved.Styles.TabActiveTitle) | |
| 35 | + | require.Equal(t, want, resolved.Styles.TabTitleActive) | |
| 36 | + | require.Equal(t, int(resolved.Accent.Slot), resolved.Styles.TabInactiveTitle.Foreground) | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 10 | 41 | func TestNeutralSessionSurfaceHierarchy(t *testing.T) { | |
| 11 | 42 | black := renderer.RGB{} | |
| 12 | 43 | white := renderer.RGB{R: 255, G: 255, B: 255} | |
| @@ -4,9 +4,40 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 6 | renderer "github.com/bnema/vev-vt" | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | // TestIndexedFallbackKeepsActiveTitleReadable covers a terminal that reports | |||
| 12 | + | // its default colors but no palette: the scheme blue fallback must not recolor | |||
| 13 | + | // the title drawn on the full-strength active surface. | |||
| 14 | + | func TestIndexedFallbackKeepsActiveTitleReadable(t *testing.T) { | |||
| 15 | + | black := renderer.RGB{} | |||
| 16 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |||
| 17 | + | for _, tt := range []struct { | |||
| 18 | + | name string | |||
| 19 | + | foreground, background renderer.RGB | |||
| 20 | + | light bool | |||
| 21 | + | }{ | |||
| 22 | + | {name: "dark", foreground: white, background: black}, | |||
| 23 | + | {name: "light", foreground: black, background: white, light: true}, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | theme := Theme{ | |||
| 27 | + | Known: true, SchemeKnown: true, Light: tt.light, UsePalette: true, | |||
| 28 | + | Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true, | |||
| 29 | + | } | |||
| 30 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 31 | + | require.True(t, resolved.Accent.IndexedOnly) | |||
| 32 | + | ||||
| 33 | + | want := MutedVariantStyle(resolved.Styles.SurfaceActive, theme) | |||
| 34 | + | require.Equal(t, want, resolved.Styles.TabActiveTitle) | |||
| 35 | + | require.Equal(t, want, resolved.Styles.TabTitleActive) | |||
| 36 | + | require.Equal(t, int(resolved.Accent.Slot), resolved.Styles.TabInactiveTitle.Foreground) | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 10 | func TestNeutralSessionSurfaceHierarchy(t *testing.T) { | 41 | func TestNeutralSessionSurfaceHierarchy(t *testing.T) { | ||
| 11 | black := renderer.RGB{} | 42 | black := renderer.RGB{} | ||
| 12 | white := renderer.RGB{R: 255, G: 255, B: 255} | 43 | white := renderer.RGB{R: 255, G: 255, B: 255} | ||
M internal/usecase/theme/resolved_styles.go +5 -1
| @@ -95,7 +95,11 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||
| 95 | 95 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |
| 96 | 96 | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | |
| 97 | 97 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | |
| 98 | - | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |
| 98 | + | // On the full-strength neutral surface an indexed title color cannot be | |
| 99 | + | // kept readable, so the active title keeps its RGB secondary text. | |
| 100 | + | if !styles.TabActiveTitle.HasBackgroundRGB { | |
| 101 | + | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |
| 102 | + | } | |
| 99 | 103 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | |
| 100 | 104 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | |
| 101 | 105 | ||
| @@ -95,7 +95,11 @@ func indexedStyles(styles Styles, slot uint8) Styles { | |||||
| 95 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | 95 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | ||
| 96 | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | 96 | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | ||
| 97 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | 97 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | ||
| 98 | - | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | 98 | + | // On the full-strength neutral surface an indexed title color cannot be |
| 99 | + | // kept readable, so the active title keeps its RGB secondary text. | |||
| 100 | + | if !styles.TabActiveTitle.HasBackgroundRGB { | |||
| 101 | + | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |||
| 102 | + | } | |||
| 99 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | 103 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | ||
| 100 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | 104 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | ||
| 101 | 105 | ||||