detached ffd16e4

fix(theme): restore indexed accent fallbacks

commit
ffd16e45e2845384ee6b2ebf637dc81b744a6b1a
author
brice · · 2026-07-18 08:05 UTC
parent
b85f942
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +230 -21
M internal/usecase/theme/accent_resolve.go +31 -6
@@ -49,17 +49,19 @@ func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent {
4949
5050 func resolveExplicitAccent(t Theme, slot uint8) Accent {
5151 if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
52- return Accent{}
52+ // An explicit policy is a strict override. Even without OSC RGB for its
53+ // slot, it may still safely decorate foregrounds and borders by index.
54+ return indexedAccent(slot)
5355 }
5456 return resolvedAccent(t, t.Palette[slot], slot)
5557 }
5658
5759 func resolveAutoAccent(t Theme) Accent {
5860 // Background is necessary to reject colors that merely alias the terminal
59- // surface. Foreground and truecolor are deliberately not required here:
60- // the winning slot remains useful as an indexed foreground-only fallback.
61+ // surface. Without it, retain the pre-accent scheme-aware ANSI blue
62+ // foreground fallback rather than inventing an RGB background.
6163 if !t.HasBG {
62- return Accent{}
64+ return schemeIndexedBlue(t)
6365 }
6466
6567 var colors [len(accentCandidateSlots)]oklab
@@ -75,7 +77,7 @@ func resolveAutoAccent(t Theme) Accent {
7577 }
7678 }
7779 if eligible == 0 {
78- return Accent{}
80+ return fallbackAutoAccent(t, background)
7981 }
8082
8183 var adjacent [len(accentCandidateSlots)]uint16
@@ -143,6 +145,10 @@ func resolveAutoAccent(t Theme) Accent {
143145 return resolvedAccent(t, t.Palette[slot], slot)
144146 }
145147
148+ return fallbackAutoAccent(t, background)
149+}
150+
151+func fallbackAutoAccent(t Theme, background oklab) Accent {
146152 for _, slot := range [...]uint8{4, 12} {
147153 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
148154 continue
@@ -152,7 +158,26 @@ func resolveAutoAccent(t Theme) Accent {
152158 return resolvedAccent(t, t.Palette[slot], slot)
153159 }
154160 }
155- return Accent{}
161+ if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 {
162+ // A terminal-reported blue that fails eligibility deliberately does not
163+ // become an unvalidated indexed fallback.
164+ return Accent{}
165+ }
166+ return schemeIndexedBlue(t)
167+}
168+
169+func schemeIndexedBlue(t Theme) Accent {
170+ if !t.SchemeKnown {
171+ return Accent{}
172+ }
173+ if t.Light {
174+ return indexedAccent(4)
175+ }
176+ return indexedAccent(12)
177+}
178+
179+func indexedAccent(slot uint8) Accent {
180+ return Accent{Slot: slot, IndexedOnly: true}
156181 }
157182
158183 func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) {
@@ -49,17 +49,19 @@ func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent {
49 49
50 func resolveExplicitAccent(t Theme, slot uint8) Accent {50 func resolveExplicitAccent(t Theme, slot uint8) Accent {
51 if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 {51 if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
52- return Accent{}52+ // An explicit policy is a strict override. Even without OSC RGB for its
53+ // slot, it may still safely decorate foregrounds and borders by index.
54+ return indexedAccent(slot)
53 }55 }
54 return resolvedAccent(t, t.Palette[slot], slot)56 return resolvedAccent(t, t.Palette[slot], slot)
55 }57 }
56 58
57 func resolveAutoAccent(t Theme) Accent {59 func resolveAutoAccent(t Theme) Accent {
58 // Background is necessary to reject colors that merely alias the terminal60 // Background is necessary to reject colors that merely alias the terminal
59- // surface. Foreground and truecolor are deliberately not required here:61+ // surface. Without it, retain the pre-accent scheme-aware ANSI blue
60- // the winning slot remains useful as an indexed foreground-only fallback.62+ // foreground fallback rather than inventing an RGB background.
61 if !t.HasBG {63 if !t.HasBG {
62- return Accent{}64+ return schemeIndexedBlue(t)
63 }65 }
64 66
65 var colors [len(accentCandidateSlots)]oklab67 var colors [len(accentCandidateSlots)]oklab
@@ -75,7 +77,7 @@ func resolveAutoAccent(t Theme) Accent {
75 }77 }
76 }78 }
77 if eligible == 0 {79 if eligible == 0 {
78- return Accent{}80+ return fallbackAutoAccent(t, background)
79 }81 }
80 82
81 var adjacent [len(accentCandidateSlots)]uint1683 var adjacent [len(accentCandidateSlots)]uint16
@@ -143,6 +145,10 @@ func resolveAutoAccent(t Theme) Accent {
143 return resolvedAccent(t, t.Palette[slot], slot)145 return resolvedAccent(t, t.Palette[slot], slot)
144 }146 }
145 147
148+ return fallbackAutoAccent(t, background)
149+}
150+
151+func fallbackAutoAccent(t Theme, background oklab) Accent {
146 for _, slot := range [...]uint8{4, 12} {152 for _, slot := range [...]uint8{4, 12} {
147 if t.PaletteKnown&(uint16(1)<<slot) == 0 {153 if t.PaletteKnown&(uint16(1)<<slot) == 0 {
148 continue154 continue
@@ -152,7 +158,26 @@ func resolveAutoAccent(t Theme) Accent {
152 return resolvedAccent(t, t.Palette[slot], slot)158 return resolvedAccent(t, t.Palette[slot], slot)
153 }159 }
154 }160 }
155- return Accent{}161+ if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 {
162+ // A terminal-reported blue that fails eligibility deliberately does not
163+ // become an unvalidated indexed fallback.
164+ return Accent{}
165+ }
166+ return schemeIndexedBlue(t)
167+}
168+
169+func schemeIndexedBlue(t Theme) Accent {
170+ if !t.SchemeKnown {
171+ return Accent{}
172+ }
173+ if t.Light {
174+ return indexedAccent(4)
175+ }
176+ return indexedAccent(12)
177+}
178+
179+func indexedAccent(slot uint8) Accent {
180+ return Accent{Slot: slot, IndexedOnly: true}
156 }181 }
157 182
158 func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) {183 func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) {
M internal/usecase/theme/accent_resolve_test.go +5 -2
@@ -18,8 +18,10 @@ func TestResolveAccentThresholds(t *testing.T) {
1818 }{
1919 {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}},
2020 {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true},
21+ {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true},
2122 {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}},
2223 {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
24+ {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
2325 }
2426 for _, tt := range tests {
2527 t.Run(tt.name, func(t *testing.T) {
@@ -28,6 +30,7 @@ func TestResolveAccentThresholds(t *testing.T) {
2830 }
2931
3032 a := oklab{}
33+ require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001}))
3134 require.True(t, accentConnected(a, oklab{L: accentClusterDistance}))
3235 require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001}))
3336 }
@@ -143,10 +146,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
143146 want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true},
144147 },
145148 {
146- name: "unknown explicit slot never substitutes fallback blue",
149+ name: "unknown explicit slot keeps its exact indexed decoration",
147150 theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
148151 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5},
149- want: Accent{},
152+ want: Accent{Slot: 5, IndexedOnly: true},
150153 },
151154 {
152155 name: "explicit known slot is indexed only without truecolor prerequisites",
@@ -18,8 +18,10 @@ func TestResolveAccentThresholds(t *testing.T) {
18 }{18 }{
19 {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}},19 {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}},
20 {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true},20 {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true},
21+ {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true},
21 {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}},22 {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}},
22 {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},23 {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
24+ {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
23 }25 }
24 for _, tt := range tests {26 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
@@ -28,6 +30,7 @@ func TestResolveAccentThresholds(t *testing.T) {
28 }30 }
29 31
30 a := oklab{}32 a := oklab{}
33+ require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001}))
31 require.True(t, accentConnected(a, oklab{L: accentClusterDistance}))34 require.True(t, accentConnected(a, oklab{L: accentClusterDistance}))
32 require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001}))35 require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001}))
33 }36 }
@@ -143,10 +146,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
143 want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true},146 want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true},
144 },147 },
145 {148 {
146- name: "unknown explicit slot never substitutes fallback blue",149+ name: "unknown explicit slot keeps its exact indexed decoration",
147 theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),150 theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
148 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5},151 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5},
149- want: Accent{},152+ want: Accent{Slot: 5, IndexedOnly: true},
150 },153 },
151 {154 {
152 name: "explicit known slot is indexed only without truecolor prerequisites",155 name: "explicit known slot is indexed only without truecolor prerequisites",
A internal/usecase/theme/phase3_review_test.go +168 -0
@@ -0,0 +1,168 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
12+ teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
13+ explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}
14+ tests := []struct {
15+ name string
16+ theme Theme
17+ policy domain.ThemeAccent
18+ want Accent
19+ }{
20+ {
21+ name: "explicit unknown slot decorates only with its exact index",
22+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}),
23+ policy: explicit,
24+ want: Accent{Slot: 5, IndexedOnly: true},
25+ },
26+ {
27+ name: "explicit known slot without truecolor is indexed only",
28+ theme: func() Theme {
29+ t := paletteTheme(map[int]renderer.RGB{5: teal})
30+ t.TrueColor = false
31+ return t
32+ }(),
33+ policy: explicit,
34+ want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true},
35+ },
36+ {
37+ name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
38+ theme: Theme{
39+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
40+ HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true,
41+ },
42+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
43+ want: Accent{Slot: 12, IndexedOnly: true},
44+ },
45+ {
46+ name: "automatic absent defaults keeps light scheme indexed blue fallback",
47+ theme: Theme{
48+ TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true,
49+ },
50+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
51+ want: Accent{Slot: 4, IndexedOnly: true},
52+ },
53+ {
54+ name: "automatic absent palette and unknown scheme stays neutral",
55+ theme: Theme{TrueColor: true, Known: true, UsePalette: true},
56+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
57+ want: Accent{},
58+ },
59+ {
60+ name: "palette off suppresses explicit indexed decoration",
61+ theme: func() Theme {
62+ t := paletteTheme(map[int]renderer.RGB{5: teal})
63+ t.UsePalette = false
64+ return t
65+ }(),
66+ policy: explicit,
67+ want: Accent{},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
74+ })
75+ }
76+}
77+
78+func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) {
79+ theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
80+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5})
81+ require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent)
82+
83+ for name, style := range legacyAndSemanticStyles(resolved.Styles) {
84+ require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name)
85+ }
86+ require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground)
87+ require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground)
88+}
89+
90+func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) {
91+ theme := rampTheme(false)
92+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
93+ theme.Palette[10] = theme.Palette[2]
94+ theme.PaletteKnown = 1<<2 | 1<<10
95+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
96+
97+ want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB)
98+ require.True(t, ok)
99+ require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB)
100+ require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast)
101+}
102+
103+func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
104+ paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
105+ paletteOff.UsePalette = false
106+ tests := []struct {
107+ name string
108+ theme Theme
109+ }{
110+ {name: "palette off", theme: paletteOff},
111+ {name: "forced dark", theme: BuiltinDark},
112+ {name: "forced light", theme: BuiltinLight},
113+ }
114+ for _, tt := range tests {
115+ t.Run(tt.name, func(t *testing.T) {
116+ got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
117+ want := prePhase3LegacyAliases(tt.theme)
118+ for name, style := range want {
119+ require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
120+ }
121+ })
122+ }
123+}
124+
125+func prePhase3LegacyAliases(t Theme) map[string]renderer.Style {
126+ status := renderer.DefaultStyle()
127+ accent := inverseStyle()
128+ border := renderer.DefaultStyle()
129+ muted := renderer.DefaultStyle()
130+ if usable(t) {
131+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
132+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
133+ border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
134+ muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
135+ }
136+ selection := accent
137+ return map[string]renderer.Style{
138+ "StatusBar": status,
139+ "Accent": accent,
140+ "Border": border,
141+ "Selection": selection,
142+ "CopyStatus": selection,
143+ "PaletteDesc": muted,
144+ "TabName": EmphasisStyle(status, t),
145+ "TabNameActive": EmphasisStyle(accent, t),
146+ "TabTitle": MutedVariantStyle(status, t),
147+ "TabTitleActive": MutedVariantStyle(accent, t),
148+ "PickerName": EmphasisStyle(renderer.DefaultStyle(), t),
149+ "PickerSelectionName": EmphasisStyle(selection, t),
150+ "PickerSelectionMuted": MutedVariantStyle(selection, t),
151+ "PickerSeparator": muted,
152+ }
153+}
154+
155+func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style {
156+ return map[string]renderer.Style{
157+ "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive,
158+ "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive,
159+ "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive,
160+ "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
161+ "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection,
162+ "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc,
163+ "TabName": styles.TabName, "TabNameActive": styles.TabNameActive,
164+ "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive,
165+ "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName,
166+ "PickerSelectionMuted": styles.PickerSelectionMuted,
167+ }
168+}
@@ -0,0 +1,168 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
12+ teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
13+ explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}
14+ tests := []struct {
15+ name string
16+ theme Theme
17+ policy domain.ThemeAccent
18+ want Accent
19+ }{
20+ {
21+ name: "explicit unknown slot decorates only with its exact index",
22+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}),
23+ policy: explicit,
24+ want: Accent{Slot: 5, IndexedOnly: true},
25+ },
26+ {
27+ name: "explicit known slot without truecolor is indexed only",
28+ theme: func() Theme {
29+ t := paletteTheme(map[int]renderer.RGB{5: teal})
30+ t.TrueColor = false
31+ return t
32+ }(),
33+ policy: explicit,
34+ want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true},
35+ },
36+ {
37+ name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
38+ theme: Theme{
39+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
40+ HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true,
41+ },
42+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
43+ want: Accent{Slot: 12, IndexedOnly: true},
44+ },
45+ {
46+ name: "automatic absent defaults keeps light scheme indexed blue fallback",
47+ theme: Theme{
48+ TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true,
49+ },
50+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
51+ want: Accent{Slot: 4, IndexedOnly: true},
52+ },
53+ {
54+ name: "automatic absent palette and unknown scheme stays neutral",
55+ theme: Theme{TrueColor: true, Known: true, UsePalette: true},
56+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
57+ want: Accent{},
58+ },
59+ {
60+ name: "palette off suppresses explicit indexed decoration",
61+ theme: func() Theme {
62+ t := paletteTheme(map[int]renderer.RGB{5: teal})
63+ t.UsePalette = false
64+ return t
65+ }(),
66+ policy: explicit,
67+ want: Accent{},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
74+ })
75+ }
76+}
77+
78+func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) {
79+ theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
80+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5})
81+ require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent)
82+
83+ for name, style := range legacyAndSemanticStyles(resolved.Styles) {
84+ require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name)
85+ }
86+ require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground)
87+ require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground)
88+}
89+
90+func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) {
91+ theme := rampTheme(false)
92+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
93+ theme.Palette[10] = theme.Palette[2]
94+ theme.PaletteKnown = 1<<2 | 1<<10
95+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
96+
97+ want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB)
98+ require.True(t, ok)
99+ require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB)
100+ require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast)
101+}
102+
103+func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
104+ paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
105+ paletteOff.UsePalette = false
106+ tests := []struct {
107+ name string
108+ theme Theme
109+ }{
110+ {name: "palette off", theme: paletteOff},
111+ {name: "forced dark", theme: BuiltinDark},
112+ {name: "forced light", theme: BuiltinLight},
113+ }
114+ for _, tt := range tests {
115+ t.Run(tt.name, func(t *testing.T) {
116+ got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
117+ want := prePhase3LegacyAliases(tt.theme)
118+ for name, style := range want {
119+ require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
120+ }
121+ })
122+ }
123+}
124+
125+func prePhase3LegacyAliases(t Theme) map[string]renderer.Style {
126+ status := renderer.DefaultStyle()
127+ accent := inverseStyle()
128+ border := renderer.DefaultStyle()
129+ muted := renderer.DefaultStyle()
130+ if usable(t) {
131+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
132+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
133+ border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
134+ muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
135+ }
136+ selection := accent
137+ return map[string]renderer.Style{
138+ "StatusBar": status,
139+ "Accent": accent,
140+ "Border": border,
141+ "Selection": selection,
142+ "CopyStatus": selection,
143+ "PaletteDesc": muted,
144+ "TabName": EmphasisStyle(status, t),
145+ "TabNameActive": EmphasisStyle(accent, t),
146+ "TabTitle": MutedVariantStyle(status, t),
147+ "TabTitleActive": MutedVariantStyle(accent, t),
148+ "PickerName": EmphasisStyle(renderer.DefaultStyle(), t),
149+ "PickerSelectionName": EmphasisStyle(selection, t),
150+ "PickerSelectionMuted": MutedVariantStyle(selection, t),
151+ "PickerSeparator": muted,
152+ }
153+}
154+
155+func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style {
156+ return map[string]renderer.Style{
157+ "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive,
158+ "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive,
159+ "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive,
160+ "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
161+ "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection,
162+ "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc,
163+ "TabName": styles.TabName, "TabNameActive": styles.TabNameActive,
164+ "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive,
165+ "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName,
166+ "PickerSelectionMuted": styles.PickerSelectionMuted,
167+ }
168+}
M internal/usecase/theme/resolved_styles.go +26 -13
@@ -28,7 +28,7 @@ func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
2828 func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
2929 if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
3030 styles := neutralStyles(t)
31- if accent.Known && accent.IndexedOnly {
31+ if accent.IndexedOnly {
3232 styles = indexedStyles(styles, accent.Slot)
3333 }
3434 return styles
@@ -54,12 +54,14 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
5454 PickerBase: ramp.SurfaceBar,
5555 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
5656 PickerDescription: barDescription,
57- PickerSeparator: ramp.BorderMuted,
58- PromptBase: ramp.SurfaceBar,
59- CopyStatus: ramp.SurfaceBar,
60- SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
57+ // Separators are secondary text on the picker base surface, not
58+ // non-text borders; they therefore require the normal 4.5:1 contrast.
59+ PickerSeparator: barDescription,
60+ PromptBase: ramp.SurfaceBar,
61+ CopyStatus: ramp.SurfaceBar,
62+ SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
6163 }
62- return withLegacyAliases(styles)
64+ return withLegacyAliases(styles, t)
6365 }
6466
6567 func secondarySurface(base renderer.Style) renderer.Style {
@@ -77,16 +79,25 @@ func secondarySurface(base renderer.Style) renderer.Style {
7779 func indexedStyles(styles Styles, slot uint8) Styles {
7880 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
7981 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
82+ styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
83+ styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
84+ styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
8085 styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
86+
87+ // Compatibility aliases historically received the scheme-aware indexed
88+ // blue fallback too. Keep the strict explicit index on each foreground
89+ // decoration without changing any neutral surface background.
8190 styles.Border = styles.BorderMuted
91+ styles.PaletteDesc = styles.PickerDescription
92+ styles.TabTitle = styles.TabInactiveTitle
93+ styles.TabTitleActive = styles.TabActiveTitle
94+ styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
8295 return styles
8396 }
8497
8598 func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
8699 style.HasForegroundRGB = false
87100 style.Foreground = int(slot)
88- style.HasBackgroundRGB = false
89- style.Background = -1
90101 return style
91102 }
92103
@@ -126,7 +137,7 @@ func neutralStyles(t Theme) Styles {
126137 CopyStatus: accent,
127138 SearchSelection: accent,
128139 }
129- return withLegacyAliases(styles)
140+ return withLegacyAliases(styles, t)
130141 }
131142
132143 func neutralBorderStyle(t Theme) renderer.Style {
@@ -136,7 +147,7 @@ func neutralBorderStyle(t Theme) renderer.Style {
136147 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
137148 }
138149
139-func withLegacyAliases(styles Styles) Styles {
150+func withLegacyAliases(styles Styles, t Theme) Styles {
140151 styles.StatusBar = styles.SurfaceBar
141152 styles.Accent = styles.SurfaceActive
142153 styles.Border = styles.BorderMuted
@@ -146,8 +157,10 @@ func withLegacyAliases(styles Styles) Styles {
146157 styles.TabNameActive = styles.TabActive
147158 styles.TabTitle = styles.TabInactiveTitle
148159 styles.TabTitleActive = styles.TabActiveTitle
149- styles.PickerName = styles.PickerBase
150- styles.PickerSelectionName = styles.PickerSelection
151- styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection)
160+ // These aliases retain their pre-semantic-role contracts for render paths
161+ // that have not migrated yet.
162+ styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t)
163+ styles.PickerSelectionName = EmphasisStyle(styles.Selection, t)
164+ styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t)
152165 return styles
153166 }
@@ -28,7 +28,7 @@ func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
28 func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {28 func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
29 if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {29 if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
30 styles := neutralStyles(t)30 styles := neutralStyles(t)
31- if accent.Known && accent.IndexedOnly {31+ if accent.IndexedOnly {
32 styles = indexedStyles(styles, accent.Slot)32 styles = indexedStyles(styles, accent.Slot)
33 }33 }
34 return styles34 return styles
@@ -54,12 +54,14 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
54 PickerBase: ramp.SurfaceBar,54 PickerBase: ramp.SurfaceBar,
55 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),55 PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
56 PickerDescription: barDescription,56 PickerDescription: barDescription,
57- PickerSeparator: ramp.BorderMuted,57+ // Separators are secondary text on the picker base surface, not
58- PromptBase: ramp.SurfaceBar,58+ // non-text borders; they therefore require the normal 4.5:1 contrast.
59- CopyStatus: ramp.SurfaceBar,59+ PickerSeparator: barDescription,
60- SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),60+ PromptBase: ramp.SurfaceBar,
61+ CopyStatus: ramp.SurfaceBar,
62+ SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
61 }63 }
62- return withLegacyAliases(styles)64+ return withLegacyAliases(styles, t)
63 }65 }
64 66
65 func secondarySurface(base renderer.Style) renderer.Style {67 func secondarySurface(base renderer.Style) renderer.Style {
@@ -77,16 +79,25 @@ func secondarySurface(base renderer.Style) renderer.Style {
77 func indexedStyles(styles Styles, slot uint8) Styles {79 func indexedStyles(styles Styles, slot uint8) Styles {
78 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)80 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
79 styles.BorderActive = indexedForeground(styles.BorderActive, slot)81 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
82+ styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
83+ styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
84+ styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
80 styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)85 styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
86+
87+ // Compatibility aliases historically received the scheme-aware indexed
88+ // blue fallback too. Keep the strict explicit index on each foreground
89+ // decoration without changing any neutral surface background.
81 styles.Border = styles.BorderMuted90 styles.Border = styles.BorderMuted
91+ styles.PaletteDesc = styles.PickerDescription
92+ styles.TabTitle = styles.TabInactiveTitle
93+ styles.TabTitleActive = styles.TabActiveTitle
94+ styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
82 return styles95 return styles
83 }96 }
84 97
85 func indexedForeground(style renderer.Style, slot uint8) renderer.Style {98 func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
86 style.HasForegroundRGB = false99 style.HasForegroundRGB = false
87 style.Foreground = int(slot)100 style.Foreground = int(slot)
88- style.HasBackgroundRGB = false
89- style.Background = -1
90 return style101 return style
91 }102 }
92 103
@@ -126,7 +137,7 @@ func neutralStyles(t Theme) Styles {
126 CopyStatus: accent,137 CopyStatus: accent,
127 SearchSelection: accent,138 SearchSelection: accent,
128 }139 }
129- return withLegacyAliases(styles)140+ return withLegacyAliases(styles, t)
130 }141 }
131 142
132 func neutralBorderStyle(t Theme) renderer.Style {143 func neutralBorderStyle(t Theme) renderer.Style {
@@ -136,7 +147,7 @@ func neutralBorderStyle(t Theme) renderer.Style {
136 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))147 return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
137 }148 }
138 149
139-func withLegacyAliases(styles Styles) Styles {150+func withLegacyAliases(styles Styles, t Theme) Styles {
140 styles.StatusBar = styles.SurfaceBar151 styles.StatusBar = styles.SurfaceBar
141 styles.Accent = styles.SurfaceActive152 styles.Accent = styles.SurfaceActive
142 styles.Border = styles.BorderMuted153 styles.Border = styles.BorderMuted
@@ -146,8 +157,10 @@ func withLegacyAliases(styles Styles) Styles {
146 styles.TabNameActive = styles.TabActive157 styles.TabNameActive = styles.TabActive
147 styles.TabTitle = styles.TabInactiveTitle158 styles.TabTitle = styles.TabInactiveTitle
148 styles.TabTitleActive = styles.TabActiveTitle159 styles.TabTitleActive = styles.TabActiveTitle
149- styles.PickerName = styles.PickerBase160+ // These aliases retain their pre-semantic-role contracts for render paths
150- styles.PickerSelectionName = styles.PickerSelection161+ // that have not migrated yet.
151- styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection)162+ styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t)
163+ styles.PickerSelectionName = EmphasisStyle(styles.Selection, t)
164+ styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t)
152 return styles165 return styles
153 }166 }

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