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) }