1package theme
2
3import renderer "github.com/bnema/vev-vt"
4
5const (
6 normalTextContrast = 4.5
7 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
17)
18
19// Ramp is the contrast-safe set of semantic terminal chrome colors.
20type Ramp struct {
21 SurfaceBar renderer.Style
22 SurfaceInactive renderer.Style
23 SurfaceRecent renderer.Style
24 SurfaceActive renderer.Style
25 BorderMuted renderer.Style
26 BorderActive renderer.Style
27 BorderWarn renderer.Style
28
29 background renderer.RGB
30 foreground renderer.RGB
31 rgb bool
32}
33
34// BuildRamp derives every RGB surface from the terminal background and one
35// resolved accent. It deliberately never uses indexed colors for backgrounds.
36func BuildRamp(t Theme, accent Accent) Ramp {
37 if !accent.Known || accent.IndexedOnly || !usable(t) {
38 return neutralRamp(t)
39 }
40
41 bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
42 if !ok {
43 return neutralRamp(t)
44 }
45 inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
46 if !ok {
47 return neutralRamp(t)
48 }
49 recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
50 if !ok {
51 return neutralRamp(t)
52 }
53 active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
54 if !ok {
55 return neutralRamp(t)
56 }
57
58 return Ramp{
59 SurfaceBar: bar,
60 SurfaceInactive: inactive,
61 SurfaceRecent: recent,
62 SurfaceActive: active,
63 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
64 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
65 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
66 background: t.Background,
67 foreground: t.Foreground,
68 rgb: true,
69 }
70}
71
72// surfaceAtOrBelow searches from the requested intensity to neutral. The
73// integer sequence is intentional: it makes contrast adaptation stable.
74func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
75 for weight := target; weight >= 0; weight-- {
76 background := okLabLerp(t.Background, accent, float64(weight)/100)
77 foreground, ok := primaryText(t, background)
78 if !ok {
79 continue
80 }
81 if _, ok := secondaryText(foreground, background); !ok {
82 continue
83 }
84 return rgbSurface(foreground, background), true
85 }
86 return renderer.Style{}, false
87}
88
89func rgbSurface(foreground, background renderer.RGB) renderer.Style {
90 style := renderer.DefaultStyle()
91 style.HasForegroundRGB = true
92 style.ForegroundRGB = foreground
93 style.HasBackgroundRGB = true
94 style.BackgroundRGB = background
95 return style
96}
97
98// primaryText chooses only the reported default foreground or background.
99func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
100 best := t.Foreground
101 bestContrast := ContrastRatio(t.Foreground, surface)
102 backgroundContrast := ContrastRatio(t.Background, surface)
103 if backgroundContrast > bestContrast {
104 best, bestContrast = t.Background, backgroundContrast
105 }
106 return best, bestContrast >= normalTextContrast
107}
108
109// secondaryText mutes toward the actual surface while retaining normal-text
110// contrast. Searching down from 40 selects the greatest permitted mute.
111func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
112 for weight := 40; weight >= 0; weight-- {
113 candidate := okLabLerp(primary, surface, float64(weight)/100)
114 if ContrastRatio(candidate, surface) >= normalTextContrast {
115 return candidate, true
116 }
117 }
118 return renderer.RGB{}, false
119}
120
121// mutedBorder starts at 60 percent and only moves toward the accent, choosing
122// the lowest weight that separates from its adjacent surface.
123func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
124 for weight := 60; weight <= 100; weight++ {
125 candidate := okLabLerp(t.Background, accent, float64(weight)/100)
126 if ContrastRatio(candidate, adjacent) >= borderContrast {
127 return foregroundStyle(candidate)
128 }
129 }
130 return neutralBorder(t)
131}
132
133// activeBorder starts at the accent endpoint and reduces only when needed.
134func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
135 for weight := 100; weight >= 0; weight-- {
136 candidate := okLabLerp(t.Background, accent, float64(weight)/100)
137 if ContrastRatio(candidate, adjacent) >= borderContrast {
138 return foregroundStyle(candidate)
139 }
140 }
141 return neutralBorder(t)
142}
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.
149func 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
160func foregroundStyle(color renderer.RGB) renderer.Style {
161 style := renderer.DefaultStyle()
162 style.HasForegroundRGB = true
163 style.ForegroundRGB = color
164 return style
165}
166
167func neutralRamp(t Theme) Ramp {
168 styles := neutralStyles(t)
169 return Ramp{
170 SurfaceBar: styles.SurfaceBar,
171 SurfaceInactive: styles.SurfaceInactive,
172 SurfaceRecent: styles.SurfaceRecent,
173 SurfaceActive: styles.SurfaceActive,
174 BorderMuted: styles.BorderMuted,
175 BorderActive: styles.BorderActive,
176 BorderWarn: styles.BorderWarn,
177 }
178}
179
180// mruWeight is the share of the way from the bar to the active surface for a
181// history entry. The newest entry always sits halfway; the remaining entries
182// split that lower half into equal steps, so the oldest stays one step above
183// the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17.
184func mruWeight(index, count int) float64 {
185 return 0.5 * (1 - float64(index)/float64(count))
186}
187
188// MRUStyle places history entries by mruWeight between the bar and the active
189// surface. The background is never moved for contrast; only the text picks the
190// better of the theme foreground and background.
191func MRUStyle(ramp Ramp, index, count int) renderer.Style {
192 if !ramp.rgb || count <= 0 {
193 return ramp.SurfaceRecent
194 }
195 index = max(0, min(index, count-1))
196 weight := mruWeight(index, count)
197 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
198 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
199 return rgbSurface(foreground, background)
200}