mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* draft(tui): add theme interface and Claude/Codex presets proposal Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md. * fix(tui): implement Claude/Codex themes on the existing palette system theme.go had replaced the tuiTheme/buildTheme/palette architecture every renderer depends on with a standalone Theme interface using the wrong lipgloss import, breaking the build and orphaning the existing theme registry, /theme command, and picker. Revert theme.go to the working implementation and add claude and codex as real palette entries in theme_palettes.go, following the file's own extension pattern. Both pass the WCAG AA contrast and gray-ramp invariants asserted across the whole registry, and are picked up automatically by --theme, ZERO_THEME, /theme, and the picker. Rewrite docs/THEMES.md to describe the actual palette-registry architecture instead of the removed interface. * fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests * style(tui): gofmt theme_select_test.go (drop trailing whitespace) The ubuntu smoke run fails on gofmt -l; this clears the only unformatted file in the draft so CI goes green. * test(tui): cover the CLI/env resolution path for the dune and neon presets TestNewThemePresetsWired only checked lookupTheme and the IsDark grouping. Add coverage for the actual --theme/ZERO_THEME precedence path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each preset's own palette rather than a built-in fallback. * fix(tui): raise Dune/Neon palette contrast and fix theme docs Darken dune's accent, blue, and red tokens so the selected-row caret, local-model dot, and diff deletion text clear WCAG AA against selBg and delBg, including after ANSI-256 downsampling. Also correct THEMES.md's description of bare /theme (it opens a live-preview picker, not a list), and add the dune/neon names to the ZERO_THEME/--theme tables in README.md and README_ZH.md. * fix(tui): keep Dune and Neon readable after ANSI-256 downsampling - Dune's accent becomes #724028: still AA on selBg and panel in truecolor (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to #875f00 at 3.81:1. Blue already survived quantization at 4.67:1. - Neon's diff bands move to hexes that quantize to distinct green and red xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000, addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of collapsing to the same grays, while keeping every truecolor invariant. - New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way a 256-color terminal does and asserts the Dune selected-row pairs stay AA and the Neon bands keep their green/red identity and mutual separation. - docs/THEMES.md is now linked from the Documentation sections of both READMEs, and the Unreleased changelog inventory says twelve themes and names dune and neon. * fix(tui): keep Neon diff content AA after quantization, fix error border - faintest becomes #74c468: line numbers now quantize to #87d75f, which clears AA on the xterm-green addBg (#005f00) where the old #58af69 quantized to 2.95:1, while staying dimmer than faint so the gray ramp stays monotonic. - addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word band's #008700 (was 4.29:1). Both rendered diff-content pairs are now asserted in the ANSI-256 test instead of documented as omissions. - cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and 3.65:1 quantized, meeting the 3:1 non-text component threshold, with the border surface asserted in both renderings. - docs/THEMES.md no longer claims the extended invariants run against the whole registry: it now says new palettes must be added to the per-palette contrast tests or given equivalent coverage.
489 lines
17 KiB
Go
489 lines
17 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"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 TestNewThemePresetsWired(t *testing.T) {
|
|
neon, ok := lookupTheme("neon")
|
|
if !ok {
|
|
t.Fatal("theme 'neon' is not registered")
|
|
}
|
|
if !neon.IsDark {
|
|
t.Error("theme 'neon' should be marked as dark")
|
|
}
|
|
|
|
dune, ok := lookupTheme("dune")
|
|
if !ok {
|
|
t.Fatal("theme 'dune' is not registered")
|
|
}
|
|
if dune.IsDark {
|
|
t.Error("theme 'dune' should be marked as light")
|
|
}
|
|
|
|
for _, name := range []string{"neon", "dune"} {
|
|
if !validThemeMode(name) {
|
|
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
|
|
}
|
|
}
|
|
|
|
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
|
|
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
|
|
}
|
|
}
|
|
|
|
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
|
|
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
|
|
// not silently fall back to a built-in.
|
|
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
|
|
defer applyTheme(themeDark, true)
|
|
|
|
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
|
|
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
|
|
}
|
|
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
|
|
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
|
|
}
|
|
|
|
applyTheme(themeMode("dune"), true)
|
|
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
|
|
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
|
|
}
|
|
|
|
applyTheme(themeMode("neon"), true)
|
|
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
|
|
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
|
|
}
|
|
}
|
|
|
|
func TestExtendedThemeContrastInvariants(t *testing.T) {
|
|
// Skip validation for old built-in themes if they have established, non-compliant palettes,
|
|
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
|
|
for _, entry := range themeRegistry {
|
|
if entry.Name != "neon" && entry.Name != "dune" {
|
|
continue
|
|
}
|
|
name, pal := entry.Name, entry.Palette
|
|
|
|
// Finding 1: Permission and status/success surfaces
|
|
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
|
|
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
|
|
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
|
|
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
|
|
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
|
|
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
|
|
}
|
|
|
|
// Finding 2: Selected row secondary text
|
|
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
|
|
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
|
|
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
|
|
// Finding 3: Diff gutter pairings
|
|
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
|
|
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
|
|
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
|
|
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
|
|
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hexChannels splits a #rrggbb token into its 8-bit channels.
|
|
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
|
|
t.Helper()
|
|
h := strings.TrimPrefix(hexColor, "#")
|
|
v, err := strconv.ParseUint(h, 16, 32)
|
|
if err != nil || len(h) != 6 {
|
|
t.Fatalf("bad hex %q", hexColor)
|
|
}
|
|
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
|
|
}
|
|
|
|
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
|
|
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
|
|
// truecolor support downsamples the palette's hex tokens before rendering.
|
|
func xterm256Hex(t *testing.T, hexColor string) string {
|
|
t.Helper()
|
|
r, g, b := hexChannels(t, hexColor)
|
|
levels := []int{0, 95, 135, 175, 215, 255}
|
|
bestR, bestG, bestB := 0, 0, 0
|
|
bestDistance := math.MaxFloat64
|
|
try := func(cr, cg, cb int) {
|
|
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
|
|
if d < bestDistance {
|
|
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
|
|
}
|
|
}
|
|
for _, cr := range levels {
|
|
for _, cg := range levels {
|
|
for _, cb := range levels {
|
|
try(cr, cg, cb)
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i < 24; i++ {
|
|
gray := 8 + 10*i
|
|
try(gray, gray, gray)
|
|
}
|
|
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
|
|
}
|
|
|
|
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
|
|
// terminal, which quantizes every token to its nearest xterm entry first.
|
|
// Guard the pairs that regressed: Dune's selected-row affordances (accent
|
|
// caret/favorite star and blue local-model dot over selBg via onSel) and
|
|
// Neon's diff bands, whose previous values all quantized to the same grays.
|
|
func TestExtendedThemeANSI256Contrast(t *testing.T) {
|
|
palettes := map[string]palette{}
|
|
for _, entry := range themeRegistry {
|
|
palettes[entry.Name] = entry.Palette
|
|
}
|
|
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
|
|
|
|
dune := palettes["dune"]
|
|
for _, pair := range []struct{ name, fg, bg string }{
|
|
{"accent on selBg", dune.accent, dune.selBg},
|
|
{"blue on selBg", dune.blue, dune.selBg},
|
|
{"faintest on selBg", dune.faintest, dune.selBg},
|
|
{"ink on selBg", dune.ink, dune.selBg},
|
|
} {
|
|
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
|
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
|
}
|
|
}
|
|
|
|
neon := palettes["neon"]
|
|
greenish := func(hexColor string) bool {
|
|
r, g, b := hexChannels(t, hexColor)
|
|
return g > r && g > b
|
|
}
|
|
reddish := func(hexColor string) bool {
|
|
r, g, b := hexChannels(t, hexColor)
|
|
return r > g && r > b
|
|
}
|
|
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
|
|
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
|
|
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
|
|
}
|
|
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
|
|
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
|
|
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
|
|
}
|
|
if q(neon.addBgWord) == q(neon.addBg) {
|
|
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
|
|
}
|
|
if q(neon.delBgWord) == q(neon.delBg) {
|
|
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
|
|
}
|
|
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
|
|
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
|
|
}
|
|
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
|
|
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
|
|
}
|
|
// The two rendered diff-content pairs a 256-color terminal actually
|
|
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
|
|
// spans (addInk/delInk on their word bands).
|
|
for _, pair := range []struct{ name, fg, bg string }{
|
|
{"faintest on addBg", neon.faintest, neon.addBg},
|
|
{"faintest on delBg", neon.faintest, neon.delBg},
|
|
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
|
|
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
|
|
} {
|
|
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
|
|
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
|
|
}
|
|
}
|
|
// Error-card borders are non-text UI components: they need 3:1 against
|
|
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
|
|
// and 256-color renderings.
|
|
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
|
|
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
|
|
}
|
|
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
|
|
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
|
|
}
|
|
}
|
|
|
|
func mustR(t *testing.T, hex string) uint32 {
|
|
t.Helper()
|
|
r, _, _, _ := lipgloss.Color(hex).RGBA()
|
|
return r
|
|
}
|