diff --git a/CHANGELOG.md b/CHANGELOG.md index c838ab426..ca68674a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -235,12 +235,12 @@ tagged. Until then, source builds report the version `dev`. GitHub issue/PR templates. - Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move and applies on select (Esc reverts). -- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`, +- Thirteen built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, - `solarized-light`, `dune`, and `neon` — selectable via `/theme `, `--theme `, or + `solarized-light`, `dune`, `dune-dark`, and `neon` — selectable via `/theme `, `--theme `, or `ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally - audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light - theme was reworked for legibility. + audited after xterm-256 downsampling (Dune Dark also after 16-color ANSI conversion); see + [docs/THEMES.md](docs/THEMES.md). The built-in light theme was reworked for legibility. - `--theme ` flag for the TUI, accepting `auto` or any registered theme (previously only the `ZERO_THEME` env var existed). - "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`, diff --git a/README.md b/README.md index 84fc86baf..61c135048 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ manifest. | Control | Effect | |---|---| | `NO_COLOR=` | disables color output | -| `ZERO_THEME=` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) | +| `ZERO_THEME=` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `dune-dark`, `solarized-light`, `dune`) | | `--theme ` | selects the TUI theme from the CLI (same names) | | `/theme` | opens the theme picker inside the TUI (live preview; `/theme ` switches directly) | | `ZERO_NO_FADE=1` | disables streaming fade animation | diff --git a/README_ZH.md b/README_ZH.md index e959dbba1..439312397 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -234,7 +234,7 @@ zero update 检查更新版本 | 控制 | 效果 | |---|---| | `NO_COLOR=<任意值>` | 禁用颜色输出 | -| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) | +| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`dune-dark`、`solarized-light`、`dune`) | | `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) | | `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) | | `ZERO_NO_FADE=1` | 禁用流式淡入动画 | diff --git a/docs/THEMES.md b/docs/THEMES.md index d5d14d165..6b70b5dcc 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -11,8 +11,14 @@ the active theme and the registered names without opening the picker. ## Dune (`dune`) -A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft -amber accent. +A warm sand-and-cream palette with charcoal ink, soft amber accent, and +quantization-safe diff bands. + +## Dune Dark (`dune-dark`) + +A dark, colorblind-friendly palette inspired by Claude Code's daltonized dark palette: +near-black surface, white ink, and a deuteranopia-adjusted brand-orange +accent. ## Neon (`neon`) @@ -28,12 +34,14 @@ adding a new `palette{...}` literal, a `themeRegistry` entry, and test coverage for the new palette (see below). Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic -WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the -selected-row band for every entry. The rendered-surface invariants beyond -those (permission surfaces, selected-row secondary text, diff gutters, and -the xterm-256 downsampling checks) are asserted per palette, not against the -whole registry: `TestExtendedThemeContrastInvariants` and -`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new -theme must be added to those tests (or given equivalent palette-specific -assertions), or CI can stay green while its permission, selected-row, and -diff surfaces ship unreadable. +WCAG AA text tokens, the gray-ramp order, the selected-row band, and (via +`TestDiffHighlightWordSpans`) the word-span *contrast* pairs for every +entry. The remaining rendered-surface invariants (permission surfaces, +selected-row secondary text, xterm-256 quantization, and the quantized +add/del band *identity* check that `addBg`/`delBg` stay green/red and +distinct) are asserted per palette, not against the whole registry: +`TestExtendedThemeContrastInvariants` and `TestExtendedThemeANSI256Contrast` +enumerate the palettes they cover. A new theme must be added to those +tests (or given equivalent palette-specific assertions), or CI can stay +green while its permission, selected-row, and quantized-diff surfaces +ship unreadable. diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 054dfdd13..34299f2d0 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -444,17 +444,17 @@ var dunePalette = palette{ muted: "#473e32", faint: "#554a3a", faintest: "#655648", - accent: "#724028", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg; the previous #7c4712 quantized to #875f00 at 3.81:1) + accent: "#5f4b30", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg) green: "#38572a", - red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1) + red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling amber: "#6d4600", - blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1) + blue: "#2f5680", gitAdd: "#38572a", gitDel: "#963328", - addBg: "#dcecd0", - delBg: "#f5dbd5", - addBgWord: "#b9dc9e", - delBgWord: "#eebba9", + addBg: "#d0f0c0", // quantizes to xterm green #d7ffd7, keeping add/del rows distinct on 256-color terminals + delBg: "#fcd8d4", // quantizes to xterm red #ffd7d7 + addBgWord: "#a5e090", + delBgWord: "#f8b8af", permBg: "#f0dfae", selBg: "#e0cf98", addInk: "#264018", @@ -465,6 +465,67 @@ var dunePalette = palette{ cardPerm: "#c2a04a", } +// duneDarkPalette is a dark theme inspired by Claude Code's colorblind-friendly +// (daltonized) dark palette. The base surface is near-black (Claude Code's true +// dark canvas), with white ink and the brand-orange accent adjusted for +// deuteranopia. Diff signals reuse the proven dark-theme structure so every +// WCAG-AA invariant still holds; the accent and status colors (cool success, +// warm error, amber warning) follow a daltonized palette so additions stay +// distinguishable under color blindness. +// +// 16-color terminals (TERM=xterm) force every token through ansi.Convert16. +// Saturated cyan/blue success and bright red error both collapse onto ANSI +// pairs that fail AA on the green/maroon diff bands (bright-blue-on-green +// 1.67:1, bright-red-on-maroon 2.74:1). Soft periwinkle success and soft pink +// error map to ANSI white, which clears AA on those bands while keeping a +// cool/warm tint in truecolor and 256-color. +var duneDarkPalette = palette{ + panel: "#0e0e10", // Claude Code dark canvas — near-black + promptBg: "#262626", // submitted user-prompt bubble + line: "#242429", // borders/separators + line2: "#414147", + ink: "#ececee", + // Gray ramp (ink > muted > faint > faintest > panel) is kept light enough + // that faintest maps to ANSI white / xterm #c6c6c6 and clears WCAG AA on the + // green add gutter under both 16-color and 256-color profiles. Prior cyan + // #55c6cd mapped to bright cyan at only 4.10:1 on ANSI green (short of AA). + muted: "#e0e0e6", // secondary text + faint: "#d4d4dc", // hints/metadata + faintest: "#c8c8d0", // line numbers/separators (and diff gutters) + // Warm sand-gold so TERM=xterm maps to ANSI yellow (brand #ff9628 maps to + // red at only 4.00:1 on the navy selection band under 16-color). + accent: "#fff080", + green: "#c8c8e9", // success — soft periwinkle (cool/daltonized). Maps to ANSI white so add-sign text stays AA (5.14:1) on the green add band under 16-color; saturated cyan/blue collapses to bright blue at 1.67:1 + red: "#ffc0c8", // error — soft pink. Maps to ANSI white so del-sign text stays AA (10.95:1) on the maroon del band under 16-color; bright red collapses to 2.74:1 on maroon + amber: "#ffcc00", // warning rgb(255,204,0) + blue: "#cceeff", // permission — light sky. Maps to bright cyan so selected-row local-model dots stay AA on black selBg under 16-color (prior #99ccff -> bright blue at 2.44:1) + gitAdd: "#c8c8e9", // matches green (daltonized cool success) + gitDel: "#ffc0c8", // matches red (daltonized warm error) + addBg: "#003500", // diff added band — quantizes to xterm green #005f00 instead of the same gray as delBg, keeping add/del rows distinct on 256-color terminals (previous #0a1f14/#240a0e both collapsed to #121212); 16-color maps to ANSI green + delBg: "#350000", // diff removed band — quantizes to xterm red #5f0000 (see addBg); 16-color maps to ANSI maroon + addBgWord: "#007400", // word-level added span — quantizes to xterm green #008700, distinct from both addBg's #005f00 and delBgWord's red + delBgWord: "#740000", // word-level removed span — quantizes to xterm red #870000 (see addBgWord) + permBg: "#1c1915", + // Near-black grays collapse to ANSI black with panel under TERM=xterm, + // erasing the full-row highlight. Deep indigo maps to navy (#000080) in + // 16-color and #000087 in 256-color, staying distinct from panel. + selBg: "#25257a", + addInk: "#f0f5d2", // changed-word text — lightened so it still clears AA (4.60:1) against addBgWord's xterm-256 quantized #008700 (the original #bdeed7 fell to 4.06:1); 16-color maps to bright yellow (4.78:1 on ANSI green) + delInk: "#fff0f0", // changed-word text — near-white pink maps to ANSI white so word spans stay AA on maroon under 16-color (prior #f2c4c4 -> bright red at 2.74:1) + onAccent: "#000000", + // Status-card borders are non-text UI (WCAG 1.4.11: >=3:1 against panel). + // Prior cardRun #3399ff mapped to ANSI bright blue at only 2.44:1 on the + // black panel under 16-color. #cceeff (same as blue) maps to bright cyan + // and clears 3:1 in truecolor, xterm-256, and ANSI-16. + // cardPerm must stay distinguishable from cardErr after ANSI conversion: + // #ffcc00 collapsed to the same ANSI red as #ff6666; pure yellow maps to + // ANSI yellow so running (cyan) / error (red) / permission (yellow) stay + // three distinct roles under 16-color and 256-color. + cardRun: "#cceeff", // running tool border: light sky / ANSI bright cyan + cardErr: "#ff6666", // failed tool border: warm red / ANSI red + cardPerm: "#fff200", // permission border: pure yellow / ANSI yellow (was #ffcc00 -> ANSI red) +} + // themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme // token (lowercase, kebab), Label is the picker display text, and IsDark groups the // picker (Dark/Light sections) and drives which built-in `auto` resolves to. @@ -491,6 +552,7 @@ var themeRegistry = []themeEntry{ {Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true}, {Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true}, {Name: "neon", Label: "Neon", Palette: neonPalette, IsDark: true}, + {Name: "dune-dark", Label: "Dune Dark", Palette: duneDarkPalette, IsDark: true}, {Name: "light", Label: "light", Palette: lightPalette, IsDark: false}, {Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false}, {Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false}, diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index d586fdded..c5fe0fb6e 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -8,6 +8,7 @@ import ( "testing" "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" ) func relLum(t *testing.T, hex string) float64 { @@ -279,14 +280,22 @@ func TestNewThemePresetsWired(t *testing.T) { t.Error("theme 'dune' should be marked as light") } - for _, name := range []string{"neon", "dune"} { + duneDark, ok := lookupTheme("dune-dark") + if !ok { + t.Fatal("theme 'dune-dark' is not registered") + } + if !duneDark.IsDark { + t.Error("theme 'dune-dark' should be marked as dark") + } + + for _, name := range []string{"neon", "dune", "dune-dark"} { 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) + if !contains(themeModes, "neon") || !contains(themeModes, "dune") || !contains(themeModes, "dune-dark") { + t.Errorf("themeModes = %v, want it to include neon, dune, and dune-dark (the /theme picker list)", themeModes) } } @@ -316,9 +325,9 @@ func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) { 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. + // but enforce strict compliance on the newly introduced 'neon', 'dune', and 'dune-dark' themes. for _, entry := range themeRegistry { - if entry.Name != "neon" && entry.Name != "dune" { + if entry.Name != "neon" && entry.Name != "dune" && entry.Name != "dune-dark" { continue } name, pal := entry.Name, entry.Palette @@ -361,6 +370,24 @@ func TestExtendedThemeContrastInvariants(t *testing.T) { if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 { t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r) } + + // Dune Dark status-card borders (running / error / permission) are + // non-text UI: WCAG 1.4.11 requires >=3:1 against the panel. cardPerm + // also frames filled permission cards, so it must clear 3:1 on permBg. + // Light Dune's cardRun/cardErr/cardPerm are unchanged from main and + // already accepted; this branch only audits the new Dune Dark tokens. + if name == "dune-dark" { + for _, pair := range []struct{ name, fg, bg string }{ + {"cardRun on panel", pal.cardRun, pal.panel}, + {"cardErr on panel", pal.cardErr, pal.panel}, + {"cardPerm on panel", pal.cardPerm, pal.panel}, + {"cardPerm on permBg", pal.cardPerm, pal.permBg}, + } { + if r := wcagRatio(t, pair.fg, pair.bg); r < 3.0 { + t.Errorf("%s: %s = %.2f < 3.0", name, pair.name, r) + } + } + } } } @@ -375,40 +402,22 @@ func hexChannels(t *testing.T, hexColor string) (int, int, int) { 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. +// xterm256Hex returns the hex of a color after colorprofile.ANSI256 conversion. 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) + c := colorprofile.ANSI256.Convert(lipgloss.Color(hexColor)) + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) } // 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. +// caret/favorite star and blue local-model dot over selBg) and +// diff bands (whose previous addBg/delBg and addBgWord/delBgWord values all +// quantized to the same grays), plus the rendered add-diff content itself +// (gutter and changed-word text, which quantization made unreadable even +// once the bands were distinct), and Neon's diff bands with the same issues. func TestExtendedThemeANSI256Contrast(t *testing.T) { palettes := map[string]palette{} for _, entry := range themeRegistry { @@ -416,19 +425,6 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } 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 @@ -437,6 +433,61 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { r, g, b := hexChannels(t, hexColor) return r > g && r > b } + + for _, themeName := range []string{"dune", "dune-dark"} { + pal, ok := palettes[themeName] + if !ok { + t.Fatalf("theme %q is not registered", themeName) + } + if sep := wcagRatio(t, q(pal.selBg), q(pal.panel)); sep < 1.10 { + t.Errorf("%s: selBg vs panel separation %.2f < 1.10 after xterm-256 quantization (%s vs %s)", themeName, sep, q(pal.selBg), q(pal.panel)) + } + for _, pair := range []struct{ name, fg, bg string }{ + {"accent on selBg", pal.accent, pal.selBg}, + {"blue on selBg", pal.blue, pal.selBg}, + {"faintest on selBg", pal.faintest, pal.selBg}, + {"ink on selBg", pal.ink, pal.selBg}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("%s: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", themeName, pair.name, r, q(pair.fg), q(pair.bg)) + } + } + if q(pal.addBg) == q(pal.delBg) || !greenish(q(pal.addBg)) || !reddish(q(pal.delBg)) { + t.Errorf("%s: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s", + themeName, pal.addBg, q(pal.addBg), pal.delBg, q(pal.delBg)) + } + if q(pal.addBgWord) == q(pal.delBgWord) || !greenish(q(pal.addBgWord)) || !reddish(q(pal.delBgWord)) { + t.Errorf("%s: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s", + themeName, pal.addBgWord, q(pal.addBgWord), pal.delBgWord, q(pal.delBgWord)) + } + if q(pal.addBgWord) == q(pal.addBg) { + t.Errorf("%s: changed span is indistinguishable from its add row after quantization (both %s)", themeName, q(pal.addBg)) + } + if q(pal.delBgWord) == q(pal.delBg) { + t.Errorf("%s: changed span is indistinguishable from its del row after quantization (both %s)", themeName, q(pal.delBg)) + } + if r := wcagRatio(t, q(pal.green), q(pal.addBg)); r < 4.5 { + t.Errorf("%s: green on addBg = %.2f < 4.5 after quantization", themeName, r) + } + if r := wcagRatio(t, q(pal.red), q(pal.delBg)); r < 4.5 { + t.Errorf("%s: red on delBg = %.2f < 4.5 after quantization", themeName, r) + } + for _, pair := range []struct{ name, fg, bg string }{ + {"faintest on addBg", pal.faintest, pal.addBg}, + {"faintest on delBg", pal.faintest, pal.delBg}, + {"addInk on addBgWord", pal.addInk, pal.addBgWord}, + {"delInk on delBgWord", pal.delInk, pal.delBgWord}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("%s: %s = %.2f < 4.5 after quantization (%s on %s)", themeName, pair.name, r, q(pair.fg), q(pair.bg)) + } + } + } + + neon, ok := palettes["neon"] + if !ok { + t.Fatal("theme \"neon\" is not registered") + } 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)) @@ -479,6 +530,124 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { 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) } + + // Dune Dark: full status-border family after xterm-256 quantization. + // Prior cardRun #3399ff was only audited in truecolor/16-color elsewhere; + // cardErr/cardPerm were never checked here. Keep running/error/permission + // borders distinct so state identity survives quantization. + duneDark, ok := palettes["dune-dark"] + if !ok { + t.Fatal("theme \"dune-dark\" is not registered") + } + for _, pair := range []struct{ name, fg, bg string }{ + {"cardRun on panel", duneDark.cardRun, duneDark.panel}, + {"cardErr on panel", duneDark.cardErr, duneDark.panel}, + {"cardPerm on panel", duneDark.cardPerm, duneDark.panel}, + {"cardPerm on permBg", duneDark.cardPerm, duneDark.permBg}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 3.0 { + t.Errorf("dune-dark: %s = %.2f < 3.0 after xterm-256 quantization (%s on %s)", + pair.name, r, q(pair.fg), q(pair.bg)) + } + } + run256, err256, perm256 := q(duneDark.cardRun), q(duneDark.cardErr), q(duneDark.cardPerm) + if run256 == err256 || run256 == perm256 || err256 == perm256 { + t.Errorf("dune-dark: status borders collapse under xterm-256: cardRun=%s cardErr=%s cardPerm=%s", + run256, err256, perm256) + } +} + +// ansi16Hex returns the hex of a color after colorprofile.ANSI conversion — +// the same path lipgloss/bubbletea use on TERM=xterm-style 16-color terminals. +func ansi16Hex(t *testing.T, hexColor string) string { + t.Helper() + c := colorprofile.ANSI.Convert(lipgloss.Color(hexColor)) + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) +} + +// TERM=xterm (and other 16-color profiles) force every palette token through +// ansi.Convert16. Saturated cyan/blue success and bright red error collapse +// onto ANSI pairs that fail AA on the green/maroon diff bands. Guard the +// rendered Dune Dark diff pairs — and the selected-row affordances that use +// the same cool success/permission tokens — after the real ANSI conversion. +func TestDuneDarkANSI16Contrast(t *testing.T) { + entry, ok := lookupTheme("dune-dark") + if !ok { + t.Fatal("theme 'dune-dark' is not registered") + } + pal := entry.Palette + q := func(hexColor string) string { return ansi16Hex(t, hexColor) } + + // Diff sign text, changed-word text, and gutter line numbers (faintest on + // addBg/delBg): users actually read these under 16-color. Prior values were + // bright-blue-on-green (1.67:1), bright-red-on-maroon (2.74:1), and + // bright-cyan-on-green (4.10:1, short of AA). All must clear WCAG AA. + for _, pair := range []struct{ name, fg, bg string }{ + {"green on addBg", pal.green, pal.addBg}, + {"red on delBg", pal.red, pal.delBg}, + {"addInk on addBgWord", pal.addInk, pal.addBgWord}, + {"delInk on delBgWord", pal.delInk, pal.delBgWord}, + {"faintest on addBg", pal.faintest, pal.addBg}, + {"faintest on delBg", pal.faintest, pal.delBg}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("dune-dark: %s = %.2f < 4.5 after ANSI 16-color conversion (%s on %s)", + pair.name, r, q(pair.fg), q(pair.bg)) + } + } + + // Selected-row band must stay distinct from the panel under 16-color + // (prior gray selBg collapsed to ANSI black with the panel). + if q(pal.selBg) == q(pal.panel) { + t.Errorf("dune-dark: selBg and panel collapse to the same ANSI 16-color (%s)", q(pal.selBg)) + } + if sep := wcagRatio(t, q(pal.selBg), q(pal.panel)); sep < 1.10 { + t.Errorf("dune-dark: selBg vs panel separation %.2f < 1.10 after ANSI 16-color conversion (%s vs %s)", + sep, q(pal.selBg), q(pal.panel)) + } + + // Selected-row affordances that share the cool success/permission tokens. + for _, pair := range []struct{ name, fg, bg string }{ + {"accent on selBg", pal.accent, pal.selBg}, + {"blue on selBg", pal.blue, pal.selBg}, + {"faintest on selBg", pal.faintest, pal.selBg}, + {"ink on selBg", pal.ink, pal.selBg}, + {"green on panel", pal.green, pal.panel}, + {"red on panel", pal.red, pal.panel}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("dune-dark: %s = %.2f < 4.5 after ANSI 16-color conversion (%s on %s)", + pair.name, r, q(pair.fg), q(pair.bg)) + } + } + + // Add/del row bands must stay distinct under 16-color even when both are + // only the basic green/maroon pair (not the same ANSI slot). + if q(pal.addBg) == q(pal.delBg) { + t.Errorf("dune-dark: addBg and delBg collapse to the same ANSI 16-color (%s)", q(pal.addBg)) + } + + // Status-card borders (running / error / permission): non-text 3:1 against + // panel, and cardPerm against permBg. Prior cardRun #3399ff was 2.44:1 + // after ANSI conversion; prior cardPerm #ffcc00 collapsed to the same + // ANSI red as cardErr, losing permission-state identity under 16-color. + for _, pair := range []struct{ name, fg, bg string }{ + {"cardRun on panel", pal.cardRun, pal.panel}, + {"cardErr on panel", pal.cardErr, pal.panel}, + {"cardPerm on panel", pal.cardPerm, pal.panel}, + {"cardPerm on permBg", pal.cardPerm, pal.permBg}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 3.0 { + t.Errorf("dune-dark: %s = %.2f < 3.0 after ANSI 16-color conversion (%s on %s)", + pair.name, r, q(pair.fg), q(pair.bg)) + } + } + run16, err16, perm16 := q(pal.cardRun), q(pal.cardErr), q(pal.cardPerm) + if run16 == err16 || run16 == perm16 || err16 == perm16 { + t.Errorf("dune-dark: status borders collapse under ANSI 16-color: cardRun=%s cardErr=%s cardPerm=%s", + run16, err16, perm16) + } } func mustR(t *testing.T, hex string) uint32 {