detached 13bce97

fix(theme): fade MRU history from near-active accent toward bar (#308)

* fix(theme): fade MRU history from near-active accent toward bar

* fix(theme): precompute MRU weights and snap fade to linear targets

* fix(theme): keep MRU fallback fade above inactive surfaces

* refactor(theme): skip rescanning the main MRU range in the fallback

* test(theme): drop removed TrueColor field from MRU fallback case
commit
13bce977280c556d1d8a88fe6d41b3b4aa40c251
author
bnema · · 2026-10-03 17:51 UTC
committer
GitHub · · 2026-10-03 17:51 UTC
parent
10d29b7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +116 -30
M internal/usecase/daemon/status_test.go +1 -1
@@ -1411,7 +1411,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
14111411 drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)
14121412 only := strings.Index(rowText(single), "only")
14131413 require.GreaterOrEqual(t, only, 0)
1414- require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")
1414+ require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface")
14151415 })
14161416
14171417 t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
@@ -1411,7 +1411,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1411 drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)1411 drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)
1412 only := strings.Index(rowText(single), "only")1412 only := strings.Index(rowText(single), "only")
1413 require.GreaterOrEqual(t, only, 0)1413 require.GreaterOrEqual(t, only, 0)
1414- require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")1414+ require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface")
1415 })1415 })
1416 1416
1417 t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {1417 t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
M internal/usecase/theme/ramp.go +71 -25
@@ -1,6 +1,10 @@
11 package theme
22
3-import renderer "github.com/bnema/vev-vt"
3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
48
59 const (
610 normalTextContrast = 4.5
@@ -27,7 +31,12 @@ type Ramp struct {
2731 BorderWarn renderer.Style
2832
2933 background renderer.RGB
34+ foreground renderer.RGB
3035 accent renderer.RGB
36+ // mruWeights lists, strongest first, the accent weights usable for the
37+ // recent-session history; it is scanned once per ramp.
38+ mruWeights [mruTopWeight - mruFloorWeight + 1]uint8
39+ mruCount int
3140 rgb bool
3241 }
3342
@@ -38,24 +47,24 @@ func BuildRamp(t Theme, accent Accent) Ramp {
3847 return neutralRamp(t)
3948 }
4049
41- bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
50+ bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8)
4251 if !ok {
4352 return neutralRamp(t)
4453 }
45- inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
54+ inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14)
4655 if !ok {
4756 return neutralRamp(t)
4857 }
49- recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
58+ recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22)
5059 if !ok {
5160 return neutralRamp(t)
5261 }
53- active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
62+ active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100)
5463 if !ok {
5564 return neutralRamp(t)
5665 }
5766
58- return Ramp{
67+ ramp := Ramp{
5968 SurfaceBar: bar,
6069 SurfaceInactive: inactive,
6170 SurfaceRecent: recent,
@@ -64,14 +73,17 @@ func BuildRamp(t Theme, accent Accent) Ramp {
6473 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
6574 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
6675 background: t.Background,
76+ foreground: t.Foreground,
6777 accent: accent.RGB,
6878 rgb: true,
6979 }
80+ ramp.scanMRUWeights(t, activeWeight, inactiveWeight)
81+ return ramp
7082 }
7183
7284 // surfaceAtOrBelow searches from the requested intensity to neutral. The
7385 // integer sequence is intentional: it makes contrast adaptation stable.
74-func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
86+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) {
7587 for weight := target; weight >= 0; weight-- {
7688 background := okLabLerp(t.Background, accent, float64(weight)/100)
7789 foreground, ok := primaryText(t, background)
@@ -81,9 +93,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style,
8193 if _, ok := secondaryText(foreground, background); !ok {
8294 continue
8395 }
84- return rgbSurface(foreground, background), true
96+ return rgbSurface(foreground, background), weight, true
8597 }
86- return renderer.Style{}, false
98+ return renderer.Style{}, 0, false
8799 }
88100
89101 func rgbSurface(foreground, background renderer.RGB) renderer.Style {
@@ -177,26 +189,60 @@ func neutralRamp(t Theme) Ramp {
177189 }
178190 }
179191
180-// MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for
181-// the oldest entry. A singleton is therefore the full recent surface.
182-func MRUStyle(ramp Ramp, index, count int) renderer.Style {
183- if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB {
184- return ramp.SurfaceRecent
185- }
186- if index < 0 {
187- index = 0
192+const (
193+ // mruTopWeight is the newest history entry: a slightly faded active
194+ // accent. mruFloorWeight keeps the oldest entry above the inactive and
195+ // bar surfaces so the whole history stays visibly accent-tinted.
196+ mruTopWeight = 85
197+ mruFloorWeight = 30
198+)
199+
200+// scanMRUWeights records the readable history weights, strongest first. It
201+// stays strictly between the active and inactive surfaces; when the main
202+// range has no readable weight it uses the whole gap between them, so extreme
203+// themes still get an ordered fade that never sinks below inactive tabs.
204+func (r *Ramp) scanMRUWeights(t Theme, activeWeight, inactiveWeight int) {
205+ r.mruCount = 0
206+ r.collectMRUWeights(t, min(mruTopWeight, activeWeight-1), mruFloorWeight)
207+ if r.mruCount == 0 {
208+ r.collectMRUWeights(t, min(mruFloorWeight-1, activeWeight-1), inactiveWeight+1)
188209 }
189- if index >= count {
190- index = count - 1
210+}
211+
212+func (r *Ramp) collectMRUWeights(t Theme, top, floor int) {
213+ for weight := top; weight >= floor; weight-- {
214+ background := okLabLerp(t.Background, r.accent, float64(weight)/100)
215+ if _, ok := primaryText(t, background); ok {
216+ r.mruWeights[r.mruCount] = uint8(weight)
217+ r.mruCount++
218+ }
191219 }
192- if !ramp.rgb {
220+}
221+
222+// MRUStyle fades history entries from just below the active accent toward
223+// the bar, recomputed per count. Targets are spaced linearly in accent weight
224+// and snapped to the nearest readable weight, so the fade is monotonically
225+// non-increasing (strict when enough readable weights exist).
226+func MRUStyle(ramp Ramp, index, count int) renderer.Style {
227+ if !ramp.rgb || count <= 0 || ramp.mruCount == 0 {
193228 return ramp.SurfaceRecent
194229 }
195- weight := 22.0 - 11.0*float64(index)/float64(count-1)
196- background := okLabLerp(ramp.background, ramp.accent, weight/100)
197- foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background)
198- if !ok {
199- return ramp.SurfaceRecent
230+ index = max(0, min(index, count-1))
231+
232+ weights := ramp.mruWeights[:ramp.mruCount]
233+ top, bottom := float64(weights[0]), float64(weights[len(weights)-1])
234+ target := top
235+ if count > 1 {
236+ target = top - (top-bottom)*float64(index)/float64(count-1)
200237 }
238+ weight := weights[0]
239+ for _, candidate := range weights[1:] {
240+ if math.Abs(float64(candidate)-target) < math.Abs(float64(weight)-target) {
241+ weight = candidate
242+ }
243+ }
244+
245+ background := okLabLerp(ramp.background, ramp.accent, float64(weight)/100)
246+ foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
201247 return rgbSurface(foreground, background)
202248 }
@@ -1,6 +1,10 @@
1 package theme1 package theme
2 2
3-import renderer "github.com/bnema/vev-vt"3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
4 8
5 const (9 const (
6 normalTextContrast = 4.510 normalTextContrast = 4.5
@@ -27,7 +31,12 @@ type Ramp struct {
27 BorderWarn renderer.Style31 BorderWarn renderer.Style
28 32
29 background renderer.RGB33 background renderer.RGB
34+ foreground renderer.RGB
30 accent renderer.RGB35 accent renderer.RGB
36+ // mruWeights lists, strongest first, the accent weights usable for the
37+ // recent-session history; it is scanned once per ramp.
38+ mruWeights [mruTopWeight - mruFloorWeight + 1]uint8
39+ mruCount int
31 rgb bool40 rgb bool
32 }41 }
33 42
@@ -38,24 +47,24 @@ func BuildRamp(t Theme, accent Accent) Ramp {
38 return neutralRamp(t)47 return neutralRamp(t)
39 }48 }
40 49
41- bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)50+ bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8)
42 if !ok {51 if !ok {
43 return neutralRamp(t)52 return neutralRamp(t)
44 }53 }
45- inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)54+ inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14)
46 if !ok {55 if !ok {
47 return neutralRamp(t)56 return neutralRamp(t)
48 }57 }
49- recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)58+ recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22)
50 if !ok {59 if !ok {
51 return neutralRamp(t)60 return neutralRamp(t)
52 }61 }
53- active, ok := surfaceAtOrBelow(t, accent.RGB, 100)62+ active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100)
54 if !ok {63 if !ok {
55 return neutralRamp(t)64 return neutralRamp(t)
56 }65 }
57 66
58- return Ramp{67+ ramp := Ramp{
59 SurfaceBar: bar,68 SurfaceBar: bar,
60 SurfaceInactive: inactive,69 SurfaceInactive: inactive,
61 SurfaceRecent: recent,70 SurfaceRecent: recent,
@@ -64,14 +73,17 @@ func BuildRamp(t Theme, accent Accent) Ramp {
64 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),73 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
65 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),74 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
66 background: t.Background,75 background: t.Background,
76+ foreground: t.Foreground,
67 accent: accent.RGB,77 accent: accent.RGB,
68 rgb: true,78 rgb: true,
69 }79 }
80+ ramp.scanMRUWeights(t, activeWeight, inactiveWeight)
81+ return ramp
70 }82 }
71 83
72 // surfaceAtOrBelow searches from the requested intensity to neutral. The84 // surfaceAtOrBelow searches from the requested intensity to neutral. The
73 // integer sequence is intentional: it makes contrast adaptation stable.85 // integer sequence is intentional: it makes contrast adaptation stable.
74-func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {86+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) {
75 for weight := target; weight >= 0; weight-- {87 for weight := target; weight >= 0; weight-- {
76 background := okLabLerp(t.Background, accent, float64(weight)/100)88 background := okLabLerp(t.Background, accent, float64(weight)/100)
77 foreground, ok := primaryText(t, background)89 foreground, ok := primaryText(t, background)
@@ -81,9 +93,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style,
81 if _, ok := secondaryText(foreground, background); !ok {93 if _, ok := secondaryText(foreground, background); !ok {
82 continue94 continue
83 }95 }
84- return rgbSurface(foreground, background), true96+ return rgbSurface(foreground, background), weight, true
85 }97 }
86- return renderer.Style{}, false98+ return renderer.Style{}, 0, false
87 }99 }
88 100
89 func rgbSurface(foreground, background renderer.RGB) renderer.Style {101 func rgbSurface(foreground, background renderer.RGB) renderer.Style {
@@ -177,26 +189,60 @@ func neutralRamp(t Theme) Ramp {
177 }189 }
178 }190 }
179 191
180-// MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for192+const (
181-// the oldest entry. A singleton is therefore the full recent surface.193+ // mruTopWeight is the newest history entry: a slightly faded active
182-func MRUStyle(ramp Ramp, index, count int) renderer.Style {194+ // accent. mruFloorWeight keeps the oldest entry above the inactive and
183- if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB {195+ // bar surfaces so the whole history stays visibly accent-tinted.
184- return ramp.SurfaceRecent196+ mruTopWeight = 85
185- }197+ mruFloorWeight = 30
186- if index < 0 {198+)
187- index = 0199+
200+// scanMRUWeights records the readable history weights, strongest first. It
201+// stays strictly between the active and inactive surfaces; when the main
202+// range has no readable weight it uses the whole gap between them, so extreme
203+// themes still get an ordered fade that never sinks below inactive tabs.
204+func (r *Ramp) scanMRUWeights(t Theme, activeWeight, inactiveWeight int) {
205+ r.mruCount = 0
206+ r.collectMRUWeights(t, min(mruTopWeight, activeWeight-1), mruFloorWeight)
207+ if r.mruCount == 0 {
208+ r.collectMRUWeights(t, min(mruFloorWeight-1, activeWeight-1), inactiveWeight+1)
188 }209 }
189- if index >= count {210+}
190- index = count - 1211+
212+func (r *Ramp) collectMRUWeights(t Theme, top, floor int) {
213+ for weight := top; weight >= floor; weight-- {
214+ background := okLabLerp(t.Background, r.accent, float64(weight)/100)
215+ if _, ok := primaryText(t, background); ok {
216+ r.mruWeights[r.mruCount] = uint8(weight)
217+ r.mruCount++
218+ }
191 }219 }
192- if !ramp.rgb {220+}
221+
222+// MRUStyle fades history entries from just below the active accent toward
223+// the bar, recomputed per count. Targets are spaced linearly in accent weight
224+// and snapped to the nearest readable weight, so the fade is monotonically
225+// non-increasing (strict when enough readable weights exist).
226+func MRUStyle(ramp Ramp, index, count int) renderer.Style {
227+ if !ramp.rgb || count <= 0 || ramp.mruCount == 0 {
193 return ramp.SurfaceRecent228 return ramp.SurfaceRecent
194 }229 }
195- weight := 22.0 - 11.0*float64(index)/float64(count-1)230+ index = max(0, min(index, count-1))
196- background := okLabLerp(ramp.background, ramp.accent, weight/100)231+
197- foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background)232+ weights := ramp.mruWeights[:ramp.mruCount]
198- if !ok {233+ top, bottom := float64(weights[0]), float64(weights[len(weights)-1])
199- return ramp.SurfaceRecent234+ target := top
235+ if count > 1 {
236+ target = top - (top-bottom)*float64(index)/float64(count-1)
200 }237 }
238+ weight := weights[0]
239+ for _, candidate := range weights[1:] {
240+ if math.Abs(float64(candidate)-target) < math.Abs(float64(weight)-target) {
241+ weight = candidate
242+ }
243+ }
244+
245+ background := okLabLerp(ramp.background, ramp.accent, float64(weight)/100)
246+ foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
201 return rgbSurface(foreground, background)247 return rgbSurface(foreground, background)
202 }248 }
M internal/usecase/theme/ramp_test.go +44 -4
@@ -121,13 +121,53 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
121121 }
122122 }
123123
124-func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
124+func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) {
125+ tests := []struct {
126+ name string
127+ theme Theme
128+ accent renderer.RGB
129+ fallback bool
130+ }{
131+ {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
132+ {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
133+ {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
134+ {name: "low contrast grey with blue fallback", theme: Theme{Foreground: renderer.RGB{R: 160, G: 160, B: 160}, Background: renderer.RGB{R: 32, G: 32, B: 32}, HasFG: true, HasBG: true, Known: true, UsePalette: true}, accent: renderer.RGB{R: 0, G: 102, B: 255}, fallback: true},
135+ }
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
139+ require.True(t, ramp.rgb)
140+ require.NotZero(t, ramp.mruCount)
141+ require.Equal(t, tt.fallback, int(ramp.mruWeights[0]) < mruFloorWeight, "fallback range use")
142+ distance := func(style renderer.Style) float64 {
143+ return okLabDistance(rgbToOKLab(style.BackgroundRGB), rgbToOKLab(tt.theme.Background))
144+ }
145+ for count := 1; count <= 9; count++ {
146+ newest := MRUStyle(ramp, 0, count)
147+ require.NotEqual(t, ramp.SurfaceActive.BackgroundRGB, newest.BackgroundRGB, "newest differs from active")
148+ if !tt.fallback {
149+ require.GreaterOrEqual(t, distance(newest), 0.7*distance(ramp.SurfaceActive), "newest stays close to the active accent")
150+ }
151+ previous := distance(ramp.SurfaceActive)
152+ for index := range count {
153+ style := MRUStyle(ramp, index, count)
154+ require.LessOrEqual(t, distance(style), previous, "history fades with age")
155+ require.Greater(t, distance(style), distance(ramp.SurfaceInactive), "oldest stays above inactive")
156+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast)
157+ previous = distance(style)
158+ }
159+ }
160+ })
161+ }
162+}
163+
164+func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) {
125165 theme := rampTheme(false)
126166 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
127- require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
167+ ramp.scanMRUWeights(theme, 15, 14)
168+
169+ require.Zero(t, ramp.mruCount)
128170 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
129- require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
130- require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
131171 }
132172
133173 func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
@@ -121,13 +121,53 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
121 }121 }
122 }122 }
123 123
124-func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {124+func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) {
125+ tests := []struct {
126+ name string
127+ theme Theme
128+ accent renderer.RGB
129+ fallback bool
130+ }{
131+ {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
132+ {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
133+ {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
134+ {name: "low contrast grey with blue fallback", theme: Theme{Foreground: renderer.RGB{R: 160, G: 160, B: 160}, Background: renderer.RGB{R: 32, G: 32, B: 32}, HasFG: true, HasBG: true, Known: true, UsePalette: true}, accent: renderer.RGB{R: 0, G: 102, B: 255}, fallback: true},
135+ }
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
139+ require.True(t, ramp.rgb)
140+ require.NotZero(t, ramp.mruCount)
141+ require.Equal(t, tt.fallback, int(ramp.mruWeights[0]) < mruFloorWeight, "fallback range use")
142+ distance := func(style renderer.Style) float64 {
143+ return okLabDistance(rgbToOKLab(style.BackgroundRGB), rgbToOKLab(tt.theme.Background))
144+ }
145+ for count := 1; count <= 9; count++ {
146+ newest := MRUStyle(ramp, 0, count)
147+ require.NotEqual(t, ramp.SurfaceActive.BackgroundRGB, newest.BackgroundRGB, "newest differs from active")
148+ if !tt.fallback {
149+ require.GreaterOrEqual(t, distance(newest), 0.7*distance(ramp.SurfaceActive), "newest stays close to the active accent")
150+ }
151+ previous := distance(ramp.SurfaceActive)
152+ for index := range count {
153+ style := MRUStyle(ramp, index, count)
154+ require.LessOrEqual(t, distance(style), previous, "history fades with age")
155+ require.Greater(t, distance(style), distance(ramp.SurfaceInactive), "oldest stays above inactive")
156+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast)
157+ previous = distance(style)
158+ }
159+ }
160+ })
161+ }
162+}
163+
164+func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) {
125 theme := rampTheme(false)165 theme := rampTheme(false)
126 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})166 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
127- require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))167+ ramp.scanMRUWeights(theme, 15, 14)
168+
169+ require.Zero(t, ramp.mruCount)
128 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))170 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
129- require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
130- require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
131 }171 }
132 172
133 func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {173 func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {

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