mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.
Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.
The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
* fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
directly instead of a raw substring, so a wrong-key regression fails.
269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
func relLum(t *testing.T, hex string) float64 {
|
|
t.Helper()
|
|
h := strings.TrimPrefix(hex, "#")
|
|
v, err := strconv.ParseUint(h, 16, 32)
|
|
if err != nil || len(h) != 6 {
|
|
t.Fatalf("bad hex %q", hex)
|
|
}
|
|
r := float64((v>>16)&0xff) / 255
|
|
g := float64((v>>8)&0xff) / 255
|
|
b := float64(v&0xff) / 255
|
|
return 0.2126*r + 0.7152*g + 0.0722*b
|
|
}
|
|
|
|
// wcagRatio is the true WCAG 2.x contrast ratio (sRGB-linearized luminance), unlike
|
|
// relLum which is a cheap perceptual ordering. Used to assert AA (>=4.5) for the
|
|
// text-bearing theme tokens.
|
|
func wcagRatio(t *testing.T, fg, bg string) float64 {
|
|
t.Helper()
|
|
rel := func(hex string) float64 {
|
|
h := strings.TrimPrefix(hex, "#")
|
|
v, err := strconv.ParseUint(h, 16, 32)
|
|
if err != nil || len(h) != 6 {
|
|
t.Fatalf("bad hex %q", hex)
|
|
}
|
|
lin := func(c float64) float64 {
|
|
c /= 255
|
|
if c <= 0.03928 {
|
|
return c / 12.92
|
|
}
|
|
return math.Pow((c+0.055)/1.055, 2.4)
|
|
}
|
|
return 0.2126*lin(float64((v>>16)&0xff)) + 0.7152*lin(float64((v>>8)&0xff)) + 0.0722*lin(float64(v&0xff))
|
|
}
|
|
l1, l2 := rel(fg), rel(bg)
|
|
if l1 < l2 {
|
|
l1, l2 = l2, l1
|
|
}
|
|
return (l1 + 0.05) / (l2 + 0.05)
|
|
}
|
|
|
|
// The word-level diff's brighter changed-span band must keep its text AA-readable
|
|
// and stay clearly distinct from the base add/del band, on both themes.
|
|
func TestDiffWordSpanContrast(t *testing.T) {
|
|
for _, entry := range themeRegistry {
|
|
name, pal := entry.Name, entry.Palette
|
|
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
|
|
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
|
|
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
|
|
}
|
|
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
|
|
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
|
|
}
|
|
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
|
|
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The highlighted picker/autocomplete row must both stand out from the panel
|
|
// AND keep its label readable. Guards the regression this fixes: the light
|
|
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
|
|
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
|
|
for _, entry := range themeRegistry {
|
|
name, pal := entry.Name, entry.Palette
|
|
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
|
|
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
|
|
}
|
|
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
|
|
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
|
|
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
|
|
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
|
|
// for light themes). Guards that a newly-added color palette can't ship illegible.
|
|
func TestAllThemesContrastAndHierarchy(t *testing.T) {
|
|
for _, entry := range themeRegistry {
|
|
pal := entry.Palette
|
|
for _, tok := range []struct {
|
|
name string
|
|
fg string
|
|
}{
|
|
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
|
|
{"faintest", pal.faintest}, {"accent", pal.accent},
|
|
} {
|
|
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
|
|
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
|
|
}
|
|
}
|
|
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
|
|
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
|
|
}
|
|
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
|
|
// rises toward the surface on light themes, falls on dark themes.
|
|
chain := []float64{
|
|
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
|
|
relLum(t, pal.faintest), relLum(t, pal.panel),
|
|
}
|
|
for i := 1; i < len(chain); i++ {
|
|
ok := chain[i] > chain[i-1]
|
|
if entry.IsDark {
|
|
ok = chain[i] < chain[i-1]
|
|
}
|
|
if !ok {
|
|
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveThemeMode precedence: explicit flag > ZERO_THEME env > auto.
|
|
func TestResolveThemeModePrecedence(t *testing.T) {
|
|
cases := []struct {
|
|
flag, env string
|
|
want themeMode
|
|
}{
|
|
{"light", "dark", themeLight}, // flag wins
|
|
{"dark", "light", themeDark}, // flag wins
|
|
{"", "light", themeLight}, // env
|
|
{"", "dark", themeDark}, // env
|
|
{"", "", themeAuto}, // default
|
|
{"garbage", "also-bad", themeAuto},
|
|
{"AUTO", "", themeAuto},
|
|
}
|
|
for _, c := range cases {
|
|
if got := resolveThemeMode(c.flag, c.env); got != c.want {
|
|
t.Errorf("resolveThemeMode(%q,%q) = %q, want %q", c.flag, c.env, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyTheme: auto resolves from background; explicit dark/light ignore it.
|
|
func TestApplyThemeResolution(t *testing.T) {
|
|
defer applyTheme(themeDark, true) // restore the global default
|
|
cases := []struct {
|
|
mode themeMode
|
|
darkBg bool
|
|
want themeMode
|
|
wantInk string
|
|
}{
|
|
{themeAuto, true, themeDark, darkPalette.ink},
|
|
{themeAuto, false, themeLight, lightPalette.ink},
|
|
{themeDark, false, themeDark, darkPalette.ink}, // explicit ignores bg
|
|
{themeLight, true, themeLight, lightPalette.ink}, // explicit ignores bg
|
|
}
|
|
for _, c := range cases {
|
|
got := applyTheme(c.mode, c.darkBg)
|
|
if got != c.want {
|
|
t.Errorf("applyTheme(%q, darkBg=%v) = %q, want %q", c.mode, c.darkBg, got, c.want)
|
|
}
|
|
wantR, wantG, wantB, _ := lipgloss.Color(c.wantInk).RGBA()
|
|
gotR, gotG, gotB, _ := zeroTheme.inkColor.RGBA()
|
|
if gotR != wantR || gotG != wantG || gotB != wantB {
|
|
t.Errorf("applyTheme(%q,%v): zeroTheme.inkColor not the %q ink", c.mode, c.darkBg, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The light palette must be a real dark-on-light set: distinct from dark, ink
|
|
// well-contrasted against the panel, accent readable, and the gray hierarchy
|
|
// (ink→faintest) ordered toward the surface so it still reads on white.
|
|
func TestLightPaletteContrastAndHierarchy(t *testing.T) {
|
|
if lightPalette.ink == darkPalette.ink || lightPalette.panel == darkPalette.panel {
|
|
t.Fatal("light palette must differ from dark")
|
|
}
|
|
inkL, panelL := relLum(t, lightPalette.ink), relLum(t, lightPalette.panel)
|
|
if panelL-inkL < 0.5 {
|
|
t.Errorf("light ink/panel contrast too low: panel=%.2f ink=%.2f", panelL, inkL)
|
|
}
|
|
// AUDIT-H5/H6/M: text-bearing tokens (incl. faint/faintest, which carry line
|
|
// numbers, diff @@/+++/---, help text, placeholders, and the accent prompt glyph)
|
|
// must meet WCAG AA (>=4.5) against the worst-case background (the panel) — a real
|
|
// contrast ratio, not just a luminance ordering.
|
|
for _, tok := range []struct {
|
|
name string
|
|
fg, bg string
|
|
}{
|
|
{"dark muted", darkPalette.muted, darkPalette.panel},
|
|
{"dark faint", darkPalette.faint, darkPalette.panel},
|
|
{"dark faintest", darkPalette.faintest, darkPalette.panel},
|
|
{"dark accent", darkPalette.accent, darkPalette.panel},
|
|
{"light muted", lightPalette.muted, lightPalette.panel},
|
|
{"light faint", lightPalette.faint, lightPalette.panel},
|
|
{"light faintest", lightPalette.faintest, lightPalette.panel},
|
|
{"light accent", lightPalette.accent, lightPalette.panel},
|
|
} {
|
|
if r := wcagRatio(t, tok.fg, tok.bg); r < 4.5 {
|
|
t.Errorf("%s contrast %.2f < 4.5 (WCAG AA): %s on %s", tok.name, r, tok.fg, tok.bg)
|
|
}
|
|
}
|
|
// Dark-on-light: ink darkest, then progressively lighter toward the surface.
|
|
chain := []float64{
|
|
relLum(t, lightPalette.ink),
|
|
relLum(t, lightPalette.muted),
|
|
relLum(t, lightPalette.faint),
|
|
relLum(t, lightPalette.faintest),
|
|
relLum(t, lightPalette.panel),
|
|
}
|
|
for i := 1; i < len(chain); i++ {
|
|
if !(chain[i] > chain[i-1]) {
|
|
t.Errorf("light hierarchy not monotonic toward surface at %d: %v", i, chain)
|
|
}
|
|
}
|
|
// Dark theme keeps the inverse ordering (light-on-dark).
|
|
dchain := []float64{
|
|
relLum(t, darkPalette.ink),
|
|
relLum(t, darkPalette.muted),
|
|
relLum(t, darkPalette.faint),
|
|
relLum(t, darkPalette.faintest),
|
|
relLum(t, darkPalette.panel),
|
|
}
|
|
for i := 1; i < len(dchain); i++ {
|
|
if !(dchain[i] < dchain[i-1]) {
|
|
t.Errorf("dark hierarchy not monotonic toward surface at %d: %v", i, dchain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// /theme switches the active theme live and shows state with no arg.
|
|
func TestHandleThemeCommand(t *testing.T) {
|
|
defer applyTheme(themeDark, true)
|
|
m := model{themeMode: themeAuto, hasDarkBg: true}
|
|
|
|
m, out := m.handleThemeCommand("light")
|
|
if m.themeMode != themeLight {
|
|
t.Fatalf("after /theme light, mode = %q", m.themeMode)
|
|
}
|
|
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
|
|
t.Error("/theme light did not swap the active palette")
|
|
}
|
|
if !strings.Contains(out, "light") {
|
|
t.Errorf("output should confirm light: %q", out)
|
|
}
|
|
|
|
m, _ = m.handleThemeCommand("dark")
|
|
if m.themeMode != themeDark {
|
|
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
|
|
}
|
|
|
|
_, state := m.handleThemeCommand("")
|
|
if !strings.Contains(state, "active theme") {
|
|
t.Errorf("no-arg /theme should show state: %q", state)
|
|
}
|
|
if _, bad := m.handleThemeCommand("solarized"); !strings.Contains(bad, "Unknown theme") {
|
|
t.Errorf("invalid theme should error: %q", bad)
|
|
}
|
|
}
|
|
|
|
func mustR(t *testing.T, hex string) uint32 {
|
|
t.Helper()
|
|
r, _, _, _ := lipgloss.Color(hex).RGBA()
|
|
return r
|
|
}
|