Skip to content
5 changes: 3 additions & 2 deletions docs/THEMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
65 changes: 35 additions & 30 deletions internal/tui/theme_palettes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 55 additions & 11 deletions internal/tui/theme_select_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down Expand Up @@ -408,15 +408,30 @@ 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 {
palettes[entry.Name] = entry.Palette
}
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},
Expand All @@ -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))
Expand Down
Loading