detached e6ae2e5

feat(theme): add amber Warn border role distinct from Info

BoxWarn and BoxInfo toasts previously shared BorderMuted and were
visually identical. Add a BorderWarn semantic role: it reuses the
accent-derived weight scale activeBorder searches (so it keeps the
accent's OKLab lightness/chroma band) but replaces the hue with a
fixed amber target via a new OKLCh hue-shift primitive, so Warn always
reads as amber regardless of the user's single accent hue. Wire
noticeStylesFrom's BoxWarn to the new role; BoxInfo/BoxError unchanged.
commit
e6ae2e5da44b1691c8932a41068209a9ea2167bf
author
brice · · 2026-07-20 17:30 UTC
parent
7a384bc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +216 -3
M internal/usecase/daemon/render_pipeline.go +3 -3
@@ -307,13 +307,13 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
307307 }
308308
309309 // noticeStylesFrom picks the toast box color per severity from the theme's
310-// existing chrome roles. A first pass: dedicated notice styling is out of
311-// scope here.
310+// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
311+// distinct from Info instead of sharing BorderMuted with it.
312312 func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
313313 return ui.NoticeStyles{
314314 Text: styles.PickerBase,
315315 BoxError: styles.BorderActive,
316- BoxWarn: styles.BorderMuted,
316+ BoxWarn: styles.BorderWarn,
317317 BoxInfo: styles.BorderMuted,
318318 }
319319 }
@@ -307,13 +307,13 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
307 }307 }
308 308
309 // noticeStylesFrom picks the toast box color per severity from the theme's309 // noticeStylesFrom picks the toast box color per severity from the theme's
310-// existing chrome roles. A first pass: dedicated notice styling is out of310+// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
311-// scope here.311+// distinct from Info instead of sharing BorderMuted with it.
312 func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {312 func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
313 return ui.NoticeStyles{313 return ui.NoticeStyles{
314 Text: styles.PickerBase,314 Text: styles.PickerBase,
315 BoxError: styles.BorderActive,315 BoxError: styles.BorderActive,
316- BoxWarn: styles.BorderMuted,316+ BoxWarn: styles.BorderWarn,
317 BoxInfo: styles.BorderMuted,317 BoxInfo: styles.BorderMuted,
318 }318 }
319 }319 }
M internal/usecase/daemon/render_pipeline_test.go +17 -0
@@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
319319 })
320320 }
321321 }
322+
323+func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324+ muted := renderer.Style{Foreground: 1}
325+ active := renderer.Style{Foreground: 2}
326+ warn := renderer.Style{Foreground: 3}
327+ styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn}
328+
329+ got := noticeStylesFrom(styles)
330+
331+ require.Equal(t, active, got.BoxError)
332+ require.Equal(t, muted, got.BoxInfo)
333+ // BoxWarn must use the dedicated BorderWarn role, not fall back to the
334+ // same muted role BoxInfo uses - that was the pre-existing bug this
335+ // role fixes (Warn and Info toasts were visually identical).
336+ require.Equal(t, warn, got.BoxWarn)
337+ require.NotEqual(t, got.BoxInfo, got.BoxWarn)
338+}
@@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
319 })319 })
320 }320 }
321 }321 }
322+
323+func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324+ muted := renderer.Style{Foreground: 1}
325+ active := renderer.Style{Foreground: 2}
326+ warn := renderer.Style{Foreground: 3}
327+ styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn}
328+
329+ got := noticeStylesFrom(styles)
330+
331+ require.Equal(t, active, got.BoxError)
332+ require.Equal(t, muted, got.BoxInfo)
333+ // BoxWarn must use the dedicated BorderWarn role, not fall back to the
334+ // same muted role BoxInfo uses - that was the pre-existing bug this
335+ // role fixes (Warn and Info toasts were visually identical).
336+ require.Equal(t, warn, got.BoxWarn)
337+ require.NotEqual(t, got.BoxInfo, got.BoxWarn)
338+}
M internal/usecase/theme/color_oklab.go +34 -0
@@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 {
5252 return math.Hypot(color.A, color.B)
5353 }
5454
55+// oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees
56+// in [0, 360).
57+type oklch struct {
58+ L float64
59+ C float64
60+ H float64
61+}
62+
63+// oklabToOKLCh converts to the polar form used to isolate hue from
64+// lightness and chroma.
65+func oklabToOKLCh(color oklab) oklch {
66+ hue := math.Atan2(color.B, color.A) * 180 / math.Pi
67+ if hue < 0 {
68+ hue += 360
69+ }
70+ return oklch{L: color.L, C: okLabChroma(color), H: hue}
71+}
72+
73+// oklchToOKLab converts back from the polar form.
74+func oklchToOKLab(color oklch) oklab {
75+ rad := color.H * math.Pi / 180
76+ return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)}
77+}
78+
79+// shiftHue replaces color's OKLCh hue with targetDegrees while preserving
80+// its lightness and chroma, so the result stays in the same value/intensity
81+// band as the source. A near-neutral source (chroma close to zero) yields a
82+// near-neutral result regardless of the target: hue is not meaningful there.
83+func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB {
84+ lch := oklabToOKLCh(rgbToOKLab(color))
85+ lch.H = targetDegrees
86+ return okLabToRGB(oklchToOKLab(lch))
87+}
88+
5589 // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
5690 // round-trip quantization changing terminal-provided colors.
5791 func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
@@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 {
52 return math.Hypot(color.A, color.B)52 return math.Hypot(color.A, color.B)
53 }53 }
54 54
55+// oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees
56+// in [0, 360).
57+type oklch struct {
58+ L float64
59+ C float64
60+ H float64
61+}
62+
63+// oklabToOKLCh converts to the polar form used to isolate hue from
64+// lightness and chroma.
65+func oklabToOKLCh(color oklab) oklch {
66+ hue := math.Atan2(color.B, color.A) * 180 / math.Pi
67+ if hue < 0 {
68+ hue += 360
69+ }
70+ return oklch{L: color.L, C: okLabChroma(color), H: hue}
71+}
72+
73+// oklchToOKLab converts back from the polar form.
74+func oklchToOKLab(color oklch) oklab {
75+ rad := color.H * math.Pi / 180
76+ return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)}
77+}
78+
79+// shiftHue replaces color's OKLCh hue with targetDegrees while preserving
80+// its lightness and chroma, so the result stays in the same value/intensity
81+// band as the source. A near-neutral source (chroma close to zero) yields a
82+// near-neutral result regardless of the target: hue is not meaningful there.
83+func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB {
84+ lch := oklabToOKLCh(rgbToOKLab(color))
85+ lch.H = targetDegrees
86+ return okLabToRGB(oklchToOKLab(lch))
87+}
88+
55 // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid89 // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
56 // round-trip quantization changing terminal-provided colors.90 // round-trip quantization changing terminal-provided colors.
57 func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {91 func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
M internal/usecase/theme/color_oklab_test.go +75 -0
@@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
9393 }
9494 }
9595
96+func TestOKLabOKLChRoundTrip(t *testing.T) {
97+ colors := []oklab{
98+ rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}),
99+ rgbToOKLab(renderer.RGB{R: 0xff}),
100+ rgbToOKLab(renderer.RGB{B: 0xff}),
101+ rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}),
102+ }
103+ for _, want := range colors {
104+ got := oklchToOKLab(oklabToOKLCh(want))
105+ if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 {
106+ t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want)
107+ }
108+ }
109+}
110+
111+func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ rgb renderer.RGB
115+ want float64
116+ }{
117+ // Known reference hues, computed with the same conversion this
118+ // package uses: red sits near 29 degrees, blue near 264, amber
119+ // (#FFBF00) near 84.
120+ {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2},
121+ {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1},
122+ }
123+ for _, tt := range tests {
124+ t.Run(tt.name, func(t *testing.T) {
125+ got := oklabToOKLCh(rgbToOKLab(tt.rgb))
126+ if got.H < 0 || got.H >= 360 {
127+ t.Fatalf("hue %v out of [0, 360) range", got.H)
128+ }
129+ if math.Abs(got.H-tt.want) > 0.5 {
130+ t.Fatalf("hue = %v, want ~%v", got.H, tt.want)
131+ }
132+ })
133+ }
134+}
135+
136+func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) {
137+ tests := []struct {
138+ name string
139+ source renderer.RGB
140+ }{
141+ {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
142+ {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}},
143+ {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}},
144+ }
145+ const target = warnHueDegrees
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source))
150+ shifted := shiftHue(tt.source, target)
151+ shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted))
152+
153+ // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for
154+ // out-of-gamut combinations; allow a small tolerance. These three
155+ // sources sit safely inside the gamut at the amber target hue
156+ // (verified independently), so a tight tolerance still catches a
157+ // broken implementation.
158+ if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 {
159+ t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L)
160+ }
161+ if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 {
162+ t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C)
163+ }
164+ if math.Abs(shiftedLCh.H-target) > 1 {
165+ t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target)
166+ }
167+ })
168+ }
169+}
170+
96171 func channelDelta(a, b uint8) uint8 {
97172 if a > b {
98173 return a - b
@@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
93 }93 }
94 }94 }
95 95
96+func TestOKLabOKLChRoundTrip(t *testing.T) {
97+ colors := []oklab{
98+ rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}),
99+ rgbToOKLab(renderer.RGB{R: 0xff}),
100+ rgbToOKLab(renderer.RGB{B: 0xff}),
101+ rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}),
102+ }
103+ for _, want := range colors {
104+ got := oklchToOKLab(oklabToOKLCh(want))
105+ if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 {
106+ t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want)
107+ }
108+ }
109+}
110+
111+func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ rgb renderer.RGB
115+ want float64
116+ }{
117+ // Known reference hues, computed with the same conversion this
118+ // package uses: red sits near 29 degrees, blue near 264, amber
119+ // (#FFBF00) near 84.
120+ {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2},
121+ {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1},
122+ }
123+ for _, tt := range tests {
124+ t.Run(tt.name, func(t *testing.T) {
125+ got := oklabToOKLCh(rgbToOKLab(tt.rgb))
126+ if got.H < 0 || got.H >= 360 {
127+ t.Fatalf("hue %v out of [0, 360) range", got.H)
128+ }
129+ if math.Abs(got.H-tt.want) > 0.5 {
130+ t.Fatalf("hue = %v, want ~%v", got.H, tt.want)
131+ }
132+ })
133+ }
134+}
135+
136+func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) {
137+ tests := []struct {
138+ name string
139+ source renderer.RGB
140+ }{
141+ {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
142+ {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}},
143+ {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}},
144+ }
145+ const target = warnHueDegrees
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source))
150+ shifted := shiftHue(tt.source, target)
151+ shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted))
152+
153+ // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for
154+ // out-of-gamut combinations; allow a small tolerance. These three
155+ // sources sit safely inside the gamut at the amber target hue
156+ // (verified independently), so a tight tolerance still catches a
157+ // broken implementation.
158+ if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 {
159+ t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L)
160+ }
161+ if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 {
162+ t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C)
163+ }
164+ if math.Abs(shiftedLCh.H-target) > 1 {
165+ t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target)
166+ }
167+ })
168+ }
169+}
170+
96 func channelDelta(a, b uint8) uint8 {171 func channelDelta(a, b uint8) uint8 {
97 if a > b {172 if a > b {
98 return a - b173 return a - b
M internal/usecase/theme/ramp.go +28 -0
@@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer"
55 const (
66 normalTextContrast = 4.5
77 borderContrast = 3.0
8+
9+ // warnHueDegrees is the fixed OKLCh hue target for the Warn border role.
10+ // Reference amber hues under this package's own rgbToOKLab: conventional
11+ // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at
12+ // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in
13+ // that band, so a warn border reads as amber/gold regardless of which
14+ // hue the user's single accent happens to occupy (blue ~256, red ~29,
15+ // green ~145 under the same conversion).
16+ warnHueDegrees = 75.0
817 )
918
1019 // Ramp is the contrast-safe set of semantic terminal chrome colors.
@@ -15,6 +24,7 @@ type Ramp struct {
1524 SurfaceActive renderer.Style
1625 BorderMuted renderer.Style
1726 BorderActive renderer.Style
27+ BorderWarn renderer.Style
1828
1929 background renderer.RGB
2030 accent renderer.RGB
@@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp {
5262 SurfaceActive: active,
5363 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
5464 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
65+ BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
5566 background: t.Background,
5667 accent: accent.RGB,
5768 rgb: true,
@@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
130141 return neutralBorder(t)
131142 }
132143
144+// warnBorder searches the same accent-derived weight scale activeBorder
145+// uses, but replaces each candidate's hue with the fixed amber target before
146+// checking contrast: the result keeps the accent's lightness/chroma band
147+// (so it belongs to the ramp) while always reading as amber, regardless of
148+// the user's accent hue.
149+func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
150+ for weight := 100; weight >= 0; weight-- {
151+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
152+ amber := shiftHue(candidate, warnHueDegrees)
153+ if ContrastRatio(amber, adjacent) >= borderContrast {
154+ return foregroundStyle(amber)
155+ }
156+ }
157+ return neutralBorder(t)
158+}
159+
133160 func foregroundStyle(color renderer.RGB) renderer.Style {
134161 style := renderer.DefaultStyle()
135162 style.HasForegroundRGB = true
@@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp {
146173 SurfaceActive: styles.SurfaceActive,
147174 BorderMuted: styles.BorderMuted,
148175 BorderActive: styles.BorderActive,
176+ BorderWarn: styles.BorderWarn,
149177 }
150178 }
151179
@@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer"
5 const (5 const (
6 normalTextContrast = 4.56 normalTextContrast = 4.5
7 borderContrast = 3.07 borderContrast = 3.0
8+
9+ // warnHueDegrees is the fixed OKLCh hue target for the Warn border role.
10+ // Reference amber hues under this package's own rgbToOKLab: conventional
11+ // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at
12+ // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in
13+ // that band, so a warn border reads as amber/gold regardless of which
14+ // hue the user's single accent happens to occupy (blue ~256, red ~29,
15+ // green ~145 under the same conversion).
16+ warnHueDegrees = 75.0
8 )17 )
9 18
10 // Ramp is the contrast-safe set of semantic terminal chrome colors.19 // Ramp is the contrast-safe set of semantic terminal chrome colors.
@@ -15,6 +24,7 @@ type Ramp struct {
15 SurfaceActive renderer.Style24 SurfaceActive renderer.Style
16 BorderMuted renderer.Style25 BorderMuted renderer.Style
17 BorderActive renderer.Style26 BorderActive renderer.Style
27+ BorderWarn renderer.Style
18 28
19 background renderer.RGB29 background renderer.RGB
20 accent renderer.RGB30 accent renderer.RGB
@@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp {
52 SurfaceActive: active,62 SurfaceActive: active,
53 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),63 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
54 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),64 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
65+ BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
55 background: t.Background,66 background: t.Background,
56 accent: accent.RGB,67 accent: accent.RGB,
57 rgb: true,68 rgb: true,
@@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
130 return neutralBorder(t)141 return neutralBorder(t)
131 }142 }
132 143
144+// warnBorder searches the same accent-derived weight scale activeBorder
145+// uses, but replaces each candidate's hue with the fixed amber target before
146+// checking contrast: the result keeps the accent's lightness/chroma band
147+// (so it belongs to the ramp) while always reading as amber, regardless of
148+// the user's accent hue.
149+func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
150+ for weight := 100; weight >= 0; weight-- {
151+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
152+ amber := shiftHue(candidate, warnHueDegrees)
153+ if ContrastRatio(amber, adjacent) >= borderContrast {
154+ return foregroundStyle(amber)
155+ }
156+ }
157+ return neutralBorder(t)
158+}
159+
133 func foregroundStyle(color renderer.RGB) renderer.Style {160 func foregroundStyle(color renderer.RGB) renderer.Style {
134 style := renderer.DefaultStyle()161 style := renderer.DefaultStyle()
135 style.HasForegroundRGB = true162 style.HasForegroundRGB = true
@@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp {
146 SurfaceActive: styles.SurfaceActive,173 SurfaceActive: styles.SurfaceActive,
147 BorderMuted: styles.BorderMuted,174 BorderMuted: styles.BorderMuted,
148 BorderActive: styles.BorderActive,175 BorderActive: styles.BorderActive,
176+ BorderWarn: styles.BorderWarn,
149 }177 }
150 }178 }
151 179
M internal/usecase/theme/ramp_test.go +50 -0
@@ -1,6 +1,7 @@
11 package theme
22
33 import (
4+ "math"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
7475 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
7576 }
7677
78+func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
79+ theme := rampTheme(false)
80+
81+ for name, accentRGB := range map[string]renderer.RGB{
82+ "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9},
83+ "red accent": {R: 0xcc, G: 0x66, B: 0x66},
84+ } {
85+ t.Run(name, func(t *testing.T) {
86+ accent := Accent{RGB: accentRGB, Known: true}
87+ ramp := BuildRamp(theme, accent)
88+
89+ require.True(t, ramp.BorderWarn.HasForegroundRGB)
90+ require.False(t, ramp.BorderWarn.HasBackgroundRGB)
91+
92+ accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H
93+ warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H
94+ distanceToTarget := func(h float64) float64 {
95+ d := math.Abs(h - warnHueDegrees)
96+ if d > 180 {
97+ d = 360 - d
98+ }
99+ return d
100+ }
101+ // Warn must land measurably closer to the amber target than the
102+ // accent itself sits, in both directions (blue and the
103+ // adversarial case of a red accent, which sits close to zero
104+ // degrees and could otherwise get confused with an
105+ // under-rotated "warm" accent border).
106+ require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue))
107+ require.Less(t, distanceToTarget(warnHue), 5.0)
108+
109+ // Distinct from the sibling borders by more than a rounding
110+ // difference: use the same OKLab distance scale accent
111+ // clustering already treats as "different colors"
112+ // (accentClusterDistance groups colors within 0.04).
113+ warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB)
114+ mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB)
115+ activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB)
116+ require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance)
117+ require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance)
118+
119+ require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast)
120+ })
121+ }
122+}
123+
77124 func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78125 theme := rampTheme(false)
79126 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
@@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
94141 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95142 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96143 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144+ require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
97145 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
98146 require.True(t, resolved.Styles.TabActive.Bold)
99147 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
@@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
112160 require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
113161 require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
114162 require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
163+ require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
164+ require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
115165
116166 theme.TrueColor = true
117167 theme.UsePalette = false
@@ -1,6 +1,7 @@
1 package theme1 package theme
2 2
3 import (3 import (
4+ "math"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
74 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)75 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
75 }76 }
76 77
78+func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
79+ theme := rampTheme(false)
80+
81+ for name, accentRGB := range map[string]renderer.RGB{
82+ "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9},
83+ "red accent": {R: 0xcc, G: 0x66, B: 0x66},
84+ } {
85+ t.Run(name, func(t *testing.T) {
86+ accent := Accent{RGB: accentRGB, Known: true}
87+ ramp := BuildRamp(theme, accent)
88+
89+ require.True(t, ramp.BorderWarn.HasForegroundRGB)
90+ require.False(t, ramp.BorderWarn.HasBackgroundRGB)
91+
92+ accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H
93+ warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H
94+ distanceToTarget := func(h float64) float64 {
95+ d := math.Abs(h - warnHueDegrees)
96+ if d > 180 {
97+ d = 360 - d
98+ }
99+ return d
100+ }
101+ // Warn must land measurably closer to the amber target than the
102+ // accent itself sits, in both directions (blue and the
103+ // adversarial case of a red accent, which sits close to zero
104+ // degrees and could otherwise get confused with an
105+ // under-rotated "warm" accent border).
106+ require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue))
107+ require.Less(t, distanceToTarget(warnHue), 5.0)
108+
109+ // Distinct from the sibling borders by more than a rounding
110+ // difference: use the same OKLab distance scale accent
111+ // clustering already treats as "different colors"
112+ // (accentClusterDistance groups colors within 0.04).
113+ warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB)
114+ mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB)
115+ activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB)
116+ require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance)
117+ require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance)
118+
119+ require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast)
120+ })
121+ }
122+}
123+
77 func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {124 func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78 theme := rampTheme(false)125 theme := rampTheme(false)
79 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})126 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
@@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
94 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)141 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)142 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)143 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144+ require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
97 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)145 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
98 require.True(t, resolved.Styles.TabActive.Bold)146 require.True(t, resolved.Styles.TabActive.Bold)
99 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)147 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
@@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
112 require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)160 require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
113 require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)161 require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
114 require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)162 require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
163+ require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
164+ require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
115 165
116 theme.TrueColor = true166 theme.TrueColor = true
117 theme.UsePalette = false167 theme.UsePalette = false
M internal/usecase/theme/resolved_styles.go +3 -0
@@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
4545 SurfaceActive: ramp.SurfaceActive,
4646 BorderMuted: ramp.BorderMuted,
4747 BorderActive: ramp.BorderActive,
48+ BorderWarn: ramp.BorderWarn,
4849 NeutralBorder: neutralBorder(t),
4950
5051 TabInactive: ramp.SurfaceInactive,
@@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style {
8182 func indexedStyles(styles Styles, slot uint8) Styles {
8283 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
8384 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
85+ styles.BorderWarn = indexedForeground(styles.BorderWarn, slot)
8486 styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
8587 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
8688 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
@@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles {
131133 SurfaceActive: accent,
132134 BorderMuted: border,
133135 BorderActive: border,
136+ BorderWarn: border,
134137 NeutralBorder: border,
135138
136139 TabInactive: EmphasisStyle(status, t),
@@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
45 SurfaceActive: ramp.SurfaceActive,45 SurfaceActive: ramp.SurfaceActive,
46 BorderMuted: ramp.BorderMuted,46 BorderMuted: ramp.BorderMuted,
47 BorderActive: ramp.BorderActive,47 BorderActive: ramp.BorderActive,
48+ BorderWarn: ramp.BorderWarn,
48 NeutralBorder: neutralBorder(t),49 NeutralBorder: neutralBorder(t),
49 50
50 TabInactive: ramp.SurfaceInactive,51 TabInactive: ramp.SurfaceInactive,
@@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style {
81 func indexedStyles(styles Styles, slot uint8) Styles {82 func indexedStyles(styles Styles, slot uint8) Styles {
82 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)83 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
83 styles.BorderActive = indexedForeground(styles.BorderActive, slot)84 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
85+ styles.BorderWarn = indexedForeground(styles.BorderWarn, slot)
84 styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)86 styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
85 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)87 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
86 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)88 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
@@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles {
131 SurfaceActive: accent,133 SurfaceActive: accent,
132 BorderMuted: border,134 BorderMuted: border,
133 BorderActive: border,135 BorderActive: border,
136+ BorderWarn: border,
134 NeutralBorder: border,137 NeutralBorder: border,
135 138
136 TabInactive: EmphasisStyle(status, t),139 TabInactive: EmphasisStyle(status, t),
M internal/usecase/theme/styles.go +6 -0
@@ -72,6 +72,12 @@ type Styles struct {
7272 SurfaceActive renderer.Style
7373 BorderMuted renderer.Style
7474 BorderActive renderer.Style
75+ // BorderWarn is the amber-hued warn-severity border. Unlike every other
76+ // role in this camaïeu palette it does not blend toward the resolved
77+ // accent color: it keeps the accent's lightness/chroma band but shifts
78+ // hue to a fixed amber target (see warnHueDegrees), so Warn reads as
79+ // amber and stays distinct from Error regardless of the accent hue.
80+ BorderWarn renderer.Style
7581 // NeutralBorder is the non-accent structural border for pane dividers and
7682 // unfocused pane title bars. It is resolved with the rest of the immutable
7783 // chrome snapshot so composition never derives theme colors.
@@ -72,6 +72,12 @@ type Styles struct {
72 SurfaceActive renderer.Style72 SurfaceActive renderer.Style
73 BorderMuted renderer.Style73 BorderMuted renderer.Style
74 BorderActive renderer.Style74 BorderActive renderer.Style
75+ // BorderWarn is the amber-hued warn-severity border. Unlike every other
76+ // role in this camaïeu palette it does not blend toward the resolved
77+ // accent color: it keeps the accent's lightness/chroma band but shifts
78+ // hue to a fixed amber target (see warnHueDegrees), so Warn reads as
79+ // amber and stays distinct from Error regardless of the accent hue.
80+ BorderWarn renderer.Style
75 // NeutralBorder is the non-accent structural border for pane dividers and81 // NeutralBorder is the non-accent structural border for pane dividers and
76 // unfocused pane title bars. It is resolved with the rest of the immutable82 // unfocused pane title bars. It is resolved with the rest of the immutable
77 // chrome snapshot so composition never derives theme colors.83 // chrome snapshot so composition never derives theme colors.

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