1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
|
package ui
import (
"fmt"
"math"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Theme is one swappable palette. Structural tokens (Bg/Surface/UI/Border/
// Faint/Text/Muted) are concrete per-theme values — each theme is an explicit
// light or dark variant, so no background adaptation is needed. The six
// accents are mapped by hue from the source ramp so sport semantics stay
// stable across themes (red = live, green = win, …); only the palette shifts.
// Values ported from a shared design-token set (oklch ramps converted to
// sRGB hex). See TASK-005.
type Theme struct {
ID string // config key, e.g. "flexoki-dark"
Name string // display name, e.g. "Flexoki Dark"
Dark bool // dark variant — cycled only when the terminal is dark
Bg lipgloss.Color // bg (paper/black)
Surface lipgloss.Color // bg-card
UI lipgloss.Color // bg-elevated
Border lipgloss.Color // border-strong
Faint lipgloss.Color // text-faint
Text lipgloss.Color // text
Muted lipgloss.Color // text-muted
Live lipgloss.Color // red — live state
Accent lipgloss.Color // blue — selection / interactive
Accent2 lipgloss.Color // purple — group labels / breadcrumb
Win lipgloss.Color // green — winner
Warn lipgloss.Color // yellow — favorites / section accent
Goal lipgloss.Color // orange — score pop
}
// themes is the registry, in cycle order. flexoki-dark is index 0 (the default
// and the original hardcoded palette).
var themes = []Theme{
{
ID: "flexoki-dark", Name: "Flexoki Dark", Dark: true,
Bg: "#100F0F", Surface: "#1C1B1A", UI: "#282726", Border: "#403E3C",
Faint: "#575653", Text: "#CECDC3", Muted: "#878580",
Live: "#D14D41", Accent: "#4385BE", Accent2: "#8B7EC8",
Win: "#879A39", Warn: "#D0A215", Goal: "#DA702C",
},
{
ID: "flexoki", Name: "Flexoki Light", Dark: false,
Bg: "#FFFCF0", Surface: "#F2F0E5", UI: "#F2F0E5", Border: "#CECDC3",
Faint: "#B7B5AC", Text: "#100F0F", Muted: "#6F6E69",
Live: "#D14D41", Accent: "#4385BE", Accent2: "#8B7EC8",
Win: "#879A39", Warn: "#D0A215", Goal: "#DA702C",
},
{
ID: "uchu-dark", Name: "Uchu Dark", Dark: true,
Bg: "#080A0D", Surface: "#202225", UI: "#383B3D", Border: "#515255",
Faint: "#6A6B6E", Text: "#E3E4E6", Muted: "#9A9C9E",
Live: "#EA3C65", Accent: "#3984F2", Accent2: "#915AD3",
Win: "#64D970", Warn: "#FEDF7B", Goal: "#FF9F5B",
},
{
ID: "uchu", Name: "Uchu Light", Dark: false,
Bg: "#FDFDFD", Surface: "#F0F0F2", UI: "#FDFDFD", Border: "#CCCCCF",
Faint: "#9A9C9E", Text: "#080A0D", Muted: "#515255",
Live: "#EA3C65", Accent: "#3984F2", Accent2: "#915AD3",
Win: "#64D970", Warn: "#FEDF7B", Goal: "#FF9F5B",
},
{
ID: "humdrum-dark", Name: "Humdrum Dark", Dark: true,
Bg: "#1F1D1A", Surface: "#282622", UI: "#32302C", Border: "#4A4740",
Faint: "#6D6A63", Text: "#E8E5DD", Muted: "#A8A49B",
Live: "#D6464D", Accent: "#0F80EA", Accent2: "#8A63DE",
Win: "#37981B", Warn: "#B07300", Goal: "#D05500",
},
{
ID: "humdrum", Name: "Humdrum Light", Dark: false,
Bg: "#F5F3EE", Surface: "#FFFFFF", UI: "#FFFFFF", Border: "#C3BFB3",
Faint: "#ADA99F", Text: "#2A2825", Muted: "#6D6A63",
Live: "#D6464D", Accent: "#0F80EA", Accent2: "#8A63DE",
Win: "#37981B", Warn: "#B07300", Goal: "#D05500",
},
}
// themeIndex returns the registry position of a theme ID, or 0 (the default)
// when the ID is empty or unknown.
func themeIndex(id string) int {
for i, t := range themes {
if t.ID == id {
return i
}
}
return 0
}
// themesFor returns the registry indices of themes matching the terminal's
// appearance (dark or light), in registry order. The 't' key cycles within
// this set so a dark terminal only offers dark palettes and vice-versa.
func themesFor(dark bool) []int {
var out []int
for i, t := range themes {
if t.Dark == dark {
out = append(out, i)
}
}
return out
}
// resolveTheme picks the starting theme index for the current appearance: the
// persisted theme if it matches, otherwise the first theme of that appearance.
func resolveTheme(id string, dark bool) int {
if i := themeIndex(id); id != "" && themes[i].Dark == dark {
return i
}
if set := themesFor(dark); len(set) > 0 {
return set[0]
}
return 0
}
// Active palette globals — read at render time across the package. Set by
// applyTheme; never written outside it. Switching is single-goroutine (a key
// in Update, before the next View), so mutating these is safe.
var (
activeTheme Theme
colBg lipgloss.Color
colBorder lipgloss.Color
colFaint lipgloss.Color
colText lipgloss.Color
colMuted lipgloss.Color
colLive lipgloss.Color
colAccent lipgloss.Color
colAccent2 lipgloss.Color
colWin lipgloss.Color
colWarn lipgloss.Color
colGoal lipgloss.Color
// colWarnHex is the bare hex (no '#') for section accents, matching colWarn —
// section colors are stored as ESPN-style hex strings (see teamColor).
colWarnHex string
)
// Style globals, rebuilt from the active palette by buildStyles().
var (
styleApp lipgloss.Style
styleTitle lipgloss.Style
styleSubtle lipgloss.Style
styleFaint lipgloss.Style
styleCard lipgloss.Style
styleCardSelected lipgloss.Style
styleScore lipgloss.Style
styleWin lipgloss.Style
styleFinal lipgloss.Style
styleLeagueTab lipgloss.Style
styleLeagueSel lipgloss.Style
styleHelp lipgloss.Style
styleErr lipgloss.Style
)
// applyTheme swaps the active palette and rebuilds every derived style.
func applyTheme(t Theme) {
activeTheme = t
colBg = t.Bg
colBorder = t.Border
colFaint = t.Faint
colText = t.Text
colMuted = t.Muted
colLive = t.Live
colAccent = t.Accent
colAccent2 = t.Accent2
colWin = t.Win
colWarn = t.Warn
colGoal = t.Goal
colWarnHex = strings.TrimPrefix(string(t.Warn), "#")
buildStyles()
}
func buildStyles() {
styleApp = lipgloss.NewStyle().Foreground(colText)
styleTitle = lipgloss.NewStyle().
Bold(true).Foreground(colBg).Background(colAccent).Padding(0, 1)
styleSubtle = lipgloss.NewStyle().Foreground(colMuted)
styleFaint = lipgloss.NewStyle().Foreground(colFaint)
styleCard = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).BorderForeground(colBorder).Padding(0, 1)
// Selected card: thick accent border only — no background fill. A bg fill
// looks like a dark box on light terminals and partially-fills anyway
// (inner colored spans reset the bg mid-line). Border + marker is
// terminal-background-agnostic and clean on both light and dark.
styleCardSelected = lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).BorderForeground(colAccent).Padding(0, 1)
styleScore = lipgloss.NewStyle().Bold(true).Foreground(colText)
styleWin = lipgloss.NewStyle().Bold(true).Foreground(colWin)
styleFinal = lipgloss.NewStyle().Foreground(colMuted)
styleLeagueTab = lipgloss.NewStyle().Padding(0, 1).Foreground(colMuted)
styleLeagueSel = lipgloss.NewStyle().Padding(0, 1).
Bold(true).Foreground(colBg).Background(colAccent)
styleHelp = lipgloss.NewStyle().Foreground(colMuted)
styleErr = lipgloss.NewStyle().Foreground(colLive).Bold(true)
}
// init applies the default theme so package-level styles are valid even for
// tests that build an App literal without going through New().
func init() { applyTheme(themes[0]) }
// teamColor returns a usable lipgloss color from an ESPN hex string, falling
// back to primary text on empty/short values. Team colors that are too
// low-contrast against the active theme background (white teams on a light
// theme, near-black teams on a dark one) are nudged toward the theme text
// color until they're readable, so they never vanish into the paper.
func teamColor(hex string) lipgloss.TerminalColor {
if len(hex) != 6 {
return colText
}
r, g, b := hexRGB("#" + hex)
return lipgloss.Color(rgbHex(contrastFix(r, g, b)))
}
// contrastFix blends (r,g,b) toward the theme text color until it clears a
// minimum contrast ratio against the theme background. Decorative team colors
// only need to be legible, not WCAG-AA, so the target is modest; a color that
// already passes is returned unchanged.
func contrastFix(r, g, b int) (int, int, int) {
const want = 1.9 // legible-on-paper, well below AA's 4.5 for body text
bl := relLuminance(hexRGB(string(colBg)))
if contrastRatio(relLuminance(r, g, b), bl) >= want {
return r, g, b
}
tr, tg, tb := hexRGB(string(colText))
for t := 0.2; t < 1.0; t += 0.2 {
nr := int(float64(r) + (float64(tr)-float64(r))*t)
ng := int(float64(g) + (float64(tg)-float64(g))*t)
nb := int(float64(b) + (float64(tb)-float64(b))*t)
if contrastRatio(relLuminance(nr, ng, nb), bl) >= want {
return nr, ng, nb
}
}
return tr, tg, tb
}
// relLuminance is the WCAG relative luminance of an sRGB color in [0,1].
func relLuminance(r, g, b int) float64 {
lin := func(c int) float64 {
v := float64(c) / 255
if v <= 0.03928 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b)
}
// contrastRatio is the WCAG contrast ratio between two relative luminances.
func contrastRatio(l1, l2 float64) float64 {
if l1 < l2 {
l1, l2 = l2, l1
}
return (l1 + 0.05) / (l2 + 0.05)
}
// liveDot returns the "●" live indicator pulsed by intensity in [0,1], so live
// games visibly breathe. Blends a dimmed live color → full live color on the
// active theme's red.
func liveDot(intensity float64) string {
r, g, b := hexRGB(string(colLive))
const dim = 0.45
lerp := func(v int) int { return int(float64(v) * (dim + (1-dim)*intensity)) }
c := lipgloss.Color(rgbHex(lerp(r), lerp(g), lerp(b)))
return lipgloss.NewStyle().Foreground(c).Render("●")
}
// hexRGB parses "#RRGGBB" into its components; bad input yields the Flexoki red.
func hexRGB(s string) (int, int, int) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0xD1, 0x4D, 0x41
}
v := func(b byte) int {
switch {
case b >= '0' && b <= '9':
return int(b - '0')
case b >= 'a' && b <= 'f':
return int(b-'a') + 10
case b >= 'A' && b <= 'F':
return int(b-'A') + 10
}
return 0
}
return v(s[0])*16 + v(s[1]), v(s[2])*16 + v(s[3]), v(s[4])*16 + v(s[5])
}
// paintBackground fills the whole frame with the active theme's background and
// text color. lipgloss emits a hard reset (\x1b[0m) after every styled span,
// which would otherwise drop the background for everything after it on a line;
// we re-assert the base fg+bg after each reset and pad every line to the full
// width, so the theme's paper color covers the entire screen regardless of the
// terminal's own background. Truecolor SGR — matches the profile lipgloss
// already uses for the app's team colors.
func paintBackground(s string, w int) string {
if colBg == "" {
return s
}
br, bg, bb := hexRGB(string(colBg))
fr, fg, fb := hexRGB(string(colText))
bgSeq := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", br, bg, bb)
base := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", fr, fg, fb) + bgSeq
const reset = "\x1b[0m"
lines := strings.Split(s, "\n")
for i, ln := range lines {
pad := w - lipgloss.Width(ln)
if pad < 0 {
pad = 0
}
// Re-assert base after each inner reset, then frame the line.
ln = strings.ReplaceAll(ln, reset, reset+base)
lines[i] = base + ln + strings.Repeat(" ", pad) + reset
}
return strings.Join(lines, "\n")
}
func rgbHex(r, g, b int) string {
const hexd = "0123456789ABCDEF"
out := []byte("#000000")
for i, v := range []int{r, g, b} {
out[1+i*2] = hexd[(v>>4)&0xF]
out[2+i*2] = hexd[v&0xF]
}
return string(out)
}
|