▍ humdrum codex / sportsball v0.1.0
license AGPL-3.0
11.2 KB raw
  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)
}