diff --git a/docs/THEMES.md b/docs/THEMES.md index d5d14d165..e462bcfca 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -11,8 +11,9 @@ 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 dark, colorblind-safe palette matching Claude Code's daltonized dark mode: +near-black surface, white ink, and a deuteranopia-adjusted brand-orange +accent. ## Neon (`neon`) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 054dfdd13..248272c5c 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -433,36 +433,41 @@ var solarizedLightPalette = palette{ cardPerm: "#c4ae63", } -// dunePalette is a warm sand-and-cream color scheme: sand/cream surface, -// charcoal ink, and a soft amber accent. +// dunePalette is a dark theme based on Claude Code's colorblind-friendly +// (daltonized) dark mode. 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 (blue success, +// red error, amber warning) follow Claude Code's daltonized palette so +// additions stay distinguishable under color blindness. var dunePalette = palette{ - panel: "#f2e9d8", - promptBg: "#e9dcbf", - line: "#d9c7a3", - line2: "#c2a97c", - ink: "#2b241a", - 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) - green: "#38572a", - red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1) - amber: "#6d4600", - blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1) - gitAdd: "#38572a", - gitDel: "#963328", - addBg: "#dcecd0", - delBg: "#f5dbd5", - addBgWord: "#b9dc9e", - delBgWord: "#eebba9", - permBg: "#f0dfae", - selBg: "#e0cf98", - addInk: "#264018", - delInk: "#5c1810", - onAccent: "#fdf6ea", - cardRun: "#b08a4a", - cardErr: "#b57560", - cardPerm: "#c2a04a", + panel: "#0e0e10", // Claude Code dark canvas — near-black + promptBg: "#262626", // submitted user-prompt bubble + line: "#242429", // borders/separators + line2: "#414147", + ink: "#ececee", + muted: "#ccccd2", // secondary text — bright gray, top of the ramp + faint: "#b8b8c0", // hints/metadata + faintest: "#55c6cd", // line numbers/separators — a neutral gray at this brightness quantizes to xterm #005f00's near-neighbor at only 2.97:1 on the add-diff gutter; nudging toward cyan (still under the faint/faintest/panel ramp) keeps AA (4.62:1) once xterm-256 rounds it + accent: "#ff9628", // brand/claude rgb(255,150,40), adjusted for deuteranopia + green: "#5eccfa", // success — brightened past Claude Code's rgb(51,153,255) so the add-diff sign text stays AA (4.80:1) against addBg once xterm-256 quantizes both to #5fd7ff/#005f00 (2.43:1 at the original value) + red: "#ff6666", // error rgb(255,102,102) + amber: "#ffcc00", // warning rgb(255,204,0) + blue: "#99ccff", // permission rgb(153,204,255) + gitAdd: "#7db87a", + gitDel: "#b87a7a", + 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) + delBg: "#350000", // diff removed band — quantizes to xterm red #5f0000 (see addBg) + 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", + selBg: "#262626", // selection — dark gray, distinct from panel (>= 1.10 contrast after xterm-256 quantization), AA with faint/faintest + 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) + delInk: "#f2c4c4", + onAccent: "#000000", + cardRun: "#3399ff", // success blue + cardErr: "#ff6666", // error + cardPerm: "#ffcc00", // warning } // themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme @@ -491,9 +496,9 @@ 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", Label: "Dune", Palette: dunePalette, 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}, } // themeByName indexes the registry by lowercased name for O(1) lookup. Built as a diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index d586fdded..c6a7b0c04 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -275,8 +275,8 @@ func TestNewThemePresetsWired(t *testing.T) { if !ok { t.Fatal("theme 'dune' is not registered") } - if dune.IsDark { - t.Error("theme 'dune' should be marked as light") + if !dune.IsDark { + t.Error("theme 'dune' should be marked as dark") } for _, name := range []string{"neon", "dune"} { @@ -408,7 +408,10 @@ func xterm256Hex(t *testing.T, hexColor string) string { // 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. +// 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,7 +419,19 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } q := func(hexColor string) string { return xterm256Hex(t, hexColor) } + 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 + } + dune := palettes["dune"] + if sep := wcagRatio(t, q(dune.selBg), q(dune.panel)); sep < 1.10 { + t.Errorf("dune: selBg vs panel separation %.2f < 1.10 after xterm-256 quantization (%s vs %s)", sep, q(dune.selBg), q(dune.panel)) + } for _, pair := range []struct{ name, fg, bg string }{ {"accent on selBg", dune.accent, dune.selBg}, {"blue on selBg", dune.blue, dune.selBg}, @@ -427,16 +442,45 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { 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 + // Dune's diff row/word bands must keep the same add/del distinctness on + // 256-color terminals that Neon's already guard below: the original + // addBg/delBg (#0a1f14/#240a0e) both quantized to the same gray (#121212), + // making added and removed lines indistinguishable. + if q(dune.addBg) == q(dune.delBg) || !greenish(q(dune.addBg)) || !reddish(q(dune.delBg)) { + t.Errorf("dune: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s", + dune.addBg, q(dune.addBg), dune.delBg, q(dune.delBg)) } - reddish := func(hexColor string) bool { - r, g, b := hexChannels(t, hexColor) - return r > g && r > b + if q(dune.addBgWord) == q(dune.delBgWord) || !greenish(q(dune.addBgWord)) || !reddish(q(dune.delBgWord)) { + t.Errorf("dune: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s", + dune.addBgWord, q(dune.addBgWord), dune.delBgWord, q(dune.delBgWord)) + } + if q(dune.addBgWord) == q(dune.addBg) { + t.Errorf("dune: changed span is indistinguishable from its add row after quantization (both %s)", q(dune.addBg)) + } + if q(dune.delBgWord) == q(dune.delBg) { + t.Errorf("dune: changed span is indistinguishable from its del row after quantization (both %s)", q(dune.delBg)) + } + if r := wcagRatio(t, q(dune.green), q(dune.addBg)); r < 4.5 { + t.Errorf("dune: green on addBg = %.2f < 4.5 after quantization", r) + } + if r := wcagRatio(t, q(dune.red), q(dune.delBg)); r < 4.5 { + t.Errorf("dune: 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). Mirrors the Neon assertions below. + for _, pair := range []struct{ name, fg, bg string }{ + {"faintest on addBg", dune.faintest, dune.addBg}, + {"faintest on delBg", dune.faintest, dune.delBg}, + {"addInk on addBgWord", dune.addInk, dune.addBgWord}, + {"delInk on delBgWord", dune.delInk, dune.delBgWord}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("dune: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg)) + } + } + + neon := palettes["neon"] 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))