1package theme
2
3import (
4 "math"
5 "testing"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/stretchr/testify/require"
10)
11
12func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
13 theme := rampTheme(false)
14 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
15 ramp := BuildRamp(theme, accent)
16
17 for name, got := range map[string]renderer.Style{
18 "bar": ramp.SurfaceBar,
19 "inactive": ramp.SurfaceInactive,
20 "recent": ramp.SurfaceRecent,
21 } {
22 require.True(t, got.HasBackgroundRGB, name)
23 }
24 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
25 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
26 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
27 require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
28 require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
29 require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
30 require.False(t, ramp.BorderMuted.HasBackgroundRGB)
31 require.False(t, ramp.BorderActive.HasBackgroundRGB)
32}
33
34func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
35 for _, light := range []bool{false, true} {
36 t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
37 theme := rampTheme(light)
38 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
39 ramp := BuildRamp(theme, accent)
40 for name, style := range map[string]renderer.Style{
41 "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
42 "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
43 } {
44 require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
45 }
46 for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
47 require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
48 }
49 })
50 }
51}
52
53func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
54 theme := Theme{
55 Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
56 Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
57 HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,
58 }
59 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
60 ramp := BuildRamp(theme, accent)
61
62 wantWeight := -1
63 for weight := 100; weight >= 0; weight-- {
64 surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
65 primary, ok := primaryText(theme, surface)
66 if !ok {
67 continue
68 }
69 if _, ok := secondaryText(primary, surface); ok {
70 wantWeight = weight
71 break
72 }
73 }
74 require.NotEqual(t, 100, wantWeight)
75 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
76}
77
78func 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
124func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
125 theme := rampTheme(false)
126 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))
128 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}
132
133func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
134 theme := rampTheme(false)
135 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
136 theme.Palette[10] = theme.Palette[2]
137 theme.PaletteKnown = 1<<2 | 1<<10
138 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
139
140 require.True(t, resolved.Accent.Known)
141 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
142 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
143 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144 require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
145 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
146 require.True(t, resolved.Styles.TabActive.Bold)
147 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
148 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
149 require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
150 require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
151 require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
152 require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
153 require.True(t, resolved.Styles.PickerSelection.Bold)
154}
155
156func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
157 theme := rampTheme(false)
158 theme.TrueColor = false
159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160 theme.PaletteKnown = 1 << 2
161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
162 require.True(t, indexed.Accent.IndexedOnly)
163 require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
164 require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
165 require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
166 require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
167 require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
168
169 theme.TrueColor = true
170 theme.UsePalette = false
171 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
172 require.False(t, off.Accent.Known)
173 require.Equal(t, neutralStyles(theme), off.Styles)
174
175 insufficient := rampTheme(false)
176 insufficient.Foreground = insufficient.Background
177 insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
178 insufficient.PaletteKnown = 1 << 2
179 neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
180 require.Equal(t, neutralStyles(insufficient), neutral.Styles)
181}
182
183func rampTheme(light bool) Theme {
184 if light {
185 return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
186 }
187 return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
188}