From ad24c0b0ad0ba95e53c64e2f42d5b2d2685cd0a8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:42:45 -0400 Subject: [PATCH 01/17] fix(tui): reassign Dune theme to dark Claude Code colorblind palette Dune was rendering as a light/sandy theme with a 55/55/55 grey surface, which did not match Claude Code's dark colorblind mode. Rebase the palette on a near-black surface (#0e0e10) with the brand-orange accent from openclaude's dark-daltonized theme, and move the warm grey to the user-prompt bubble only. Brighten the muted/faint/faintest gray ramp and darken selBg/addBg/delBg so all WCAG-AA and xterm-256 contrast invariants hold. Updates the stale test that still marked Dune as light. Refs #709 --- internal/tui/theme_palettes.go | 65 +++++++++++++++++-------------- internal/tui/theme_select_test.go | 4 +- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 054dfdd13..6cbdc7555 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: "#a0a0a8", // line numbers/separators — bright enough to hold AA on the near-black selBg/addBg/delBg after xterm-256 quantization + accent: "#ff9628", // brand/claude rgb(255,150,40), adjusted for deuteranopia + green: "#3399ff", // success rgb(51,153,255) — blue under colorblind mode + 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: "#0a1f14", // diff added band — near-black so faintest/green hold AA + delBg: "#240a0e", // diff removed band — near-black so faintest/red hold AA + addBgWord: "#1f4d33", // word-level added span — distinct from addBg band + delBgWord: "#4d1620", // word-level removed span — distinct from delBg band + permBg: "#1c1915", + selBg: "#191c1f", // selection — near-black, distinct from panel, AA with faint/faintest + addInk: "#bdeed7", + 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..9538d0125 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"} { From 059160008e0d4f65269c1f5f22eb56158a446ec6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:23:19 -0400 Subject: [PATCH 02/17] ci: re-trigger checks after Windows prewarm flake Empty commit to re-run CI. Prior Windows smoke failed on TestRunExecOptimizedSessionUnderGate (0 prewarm HEAD probes), which is unrelated to the Dune palette change. From f3788a19bec9131b29698bb3d6b3ecee050f51e8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:39:27 -0400 Subject: [PATCH 03/17] fix(daemon): wait for tracked worker before draining in pool test waitFor polled QueueDepth(), which flips as soon as a slot is leased, before the worker is registered in p.active by track(). Drain() only sees stragglers in p.active, so a wide enough scheduling window let it observe an empty active set and return without killing anything. Windows CI's coarser goroutine scheduling was wide enough to hit this reliably; wait on WorkerStats() instead, which reflects p.active. --- internal/daemon/pool_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/daemon/pool_test.go b/internal/daemon/pool_test.go index 7ced2bef4..68df7ed63 100644 --- a/internal/daemon/pool_test.go +++ b/internal/daemon/pool_test.go @@ -224,7 +224,9 @@ func TestPoolDrainKillsStraggler(t *testing.T) { _, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{}) close(runDone) }() - waitFor(t, func() bool { return pool.QueueDepth() == 1 }) + // QueueDepth flips as soon as a slot is leased, before the worker is tracked + // in p.active; wait for the latter so Drain is guaranteed to see it. + waitFor(t, func() bool { return len(pool.WorkerStats()) == 1 }) pool.Drain() // KillTimeout elapses, straggler is force-killed if atomic.LoadInt32(&straggler.killed) != 1 { From c8ee46c19bf14b21c56eb2b3d7ab94934ba09854 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:35:10 -0400 Subject: [PATCH 04/17] fix(tui): distinguish Dune diff colors on 256-color terminals Dune's addBg/delBg (#0a1f14/#240a0e) and addBgWord/delBgWord (#1f4d33/#4d1620) each quantized to the same xterm-256 gray (#121212 and #303030), making added and removed diff lines indistinguishable on terminals without truecolor support. Rebase the four diff band colors on stronger green/red channel separation so they quantize to distinct xterm cube colors, following the pattern Neon already uses. Adds the Dune equivalent of Neon's ANSI-256 diff-band assertions in TestExtendedThemeANSI256Contrast. Also updates the Dune entry in docs/THEMES.md, which still described the old warm sand/cream/amber palette instead of the dark colorblind-safe one this PR introduced. --- docs/THEMES.md | 5 ++-- internal/tui/theme_palettes.go | 8 +++---- internal/tui/theme_select_test.go | 38 +++++++++++++++++++++++-------- 3 files changed, 36 insertions(+), 15 deletions(-) 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 6cbdc7555..e55790501 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -456,10 +456,10 @@ var dunePalette = palette{ blue: "#99ccff", // permission rgb(153,204,255) gitAdd: "#7db87a", gitDel: "#b87a7a", - addBg: "#0a1f14", // diff added band — near-black so faintest/green hold AA - delBg: "#240a0e", // diff removed band — near-black so faintest/red hold AA - addBgWord: "#1f4d33", // word-level added span — distinct from addBg band - delBgWord: "#4d1620", // word-level removed span — distinct from delBg band + 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: "#191c1f", // selection — near-black, distinct from panel, AA with faint/faintest addInk: "#bdeed7", diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 9538d0125..fc1eb00e4 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -408,7 +408,8 @@ 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), and Neon's diff bands with the same issue. func TestExtendedThemeANSI256Contrast(t *testing.T) { palettes := map[string]palette{} for _, entry := range themeRegistry { @@ -416,6 +417,15 @@ 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"] for _, pair := range []struct{ name, fg, bg string }{ {"accent on selBg", dune.accent, dune.selBg}, @@ -427,16 +437,26 @@ 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)) } + + 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)) From a193a7270d4729a54a2990456baeaf19b3a63b72 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:09:25 -0400 Subject: [PATCH 05/17] fix(tui): fix Dune add-diff contrast on 256-color terminals The prior fix made Dune's add and del diff bands quantize to distinct xterm-256 colors, but the foreground/background pairs rendered on top of them were not checked and fail AA once quantized: green on addBg drops to 2.43:1, faintest on addBg to 2.97:1, and addInk on addBgWord to 4.06:1. Brighten green, faintest, and addInk so each pairing clears 4.5:1 after xterm-256 quantization, matching the bar Neon already holds. Add the equivalent Dune assertions to TestExtendedThemeANSI256Contrast, mirroring the existing Neon checks for these rendered text pairs. --- internal/tui/theme_palettes.go | 6 +++--- internal/tui/theme_select_test.go | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index e55790501..355a739ba 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -448,9 +448,9 @@ var dunePalette = palette{ ink: "#ececee", muted: "#ccccd2", // secondary text — bright gray, top of the ramp faint: "#b8b8c0", // hints/metadata - faintest: "#a0a0a8", // line numbers/separators — bright enough to hold AA on the near-black selBg/addBg/delBg after xterm-256 quantization + 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: "#3399ff", // success rgb(51,153,255) — blue under colorblind mode + 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) @@ -462,7 +462,7 @@ var dunePalette = palette{ delBgWord: "#740000", // word-level removed span — quantizes to xterm red #870000 (see addBgWord) permBg: "#1c1915", selBg: "#191c1f", // selection — near-black, distinct from panel, AA with faint/faintest - addInk: "#bdeed7", + 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 diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index fc1eb00e4..5a7d1d6d5 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -409,7 +409,9 @@ func xterm256Hex(t *testing.T, hexColor string) string { // Guard the pairs that regressed: Dune's selected-row affordances (accent // caret/favorite star and blue local-model dot over selBg via onSel) and // diff bands (whose previous addBg/delBg and addBgWord/delBgWord values all -// quantized to the same grays), and Neon's diff bands with the same issue. +// 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 { @@ -455,6 +457,25 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { 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)) { From f9e41b2f5beed19f449185d9b8414bf521cac75c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:06:09 -0400 Subject: [PATCH 06/17] fix(tui): increase Dune selBg ANSI-256 contrast and revert pool_test change --- internal/tui/theme_palettes.go | 2 +- internal/tui/theme_select_test.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 355a739ba..248272c5c 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -461,7 +461,7 @@ var dunePalette = palette{ 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: "#191c1f", // selection — near-black, distinct from panel, AA with faint/faintest + 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", diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 5a7d1d6d5..c6a7b0c04 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -429,6 +429,9 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } 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}, From a57cf8ab03038356ff7c2cb1e833cdf8622b27f6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:36:57 -0400 Subject: [PATCH 07/17] fix(tui): remove unrelated daemon pool test change to focus PR scope --- internal/daemon/pool_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/daemon/pool_test.go b/internal/daemon/pool_test.go index 68df7ed63..7ced2bef4 100644 --- a/internal/daemon/pool_test.go +++ b/internal/daemon/pool_test.go @@ -224,9 +224,7 @@ func TestPoolDrainKillsStraggler(t *testing.T) { _, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{}) close(runDone) }() - // QueueDepth flips as soon as a slot is leased, before the worker is tracked - // in p.active; wait for the latter so Drain is guaranteed to see it. - waitFor(t, func() bool { return len(pool.WorkerStats()) == 1 }) + waitFor(t, func() bool { return pool.QueueDepth() == 1 }) pool.Drain() // KillTimeout elapses, straggler is force-killed if atomic.LoadInt32(&straggler.killed) != 1 { From 06d37d4a419d245b082443f26d9f25d909b39401 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:20:49 -0400 Subject: [PATCH 08/17] fix(tui): restore light Dune diff contrast and add Dune Dark theme Keep Dune as a warm sand-and-cream light theme with quantization-safe diff bands (#d0f0c0 / #fcd8d4) for 256-color terminals, and register dune-dark as a dark colorblind-friendly theme. Refs #841 Refs #713 --- docs/THEMES.md | 5 ++ internal/tui/theme_palettes.go | 43 ++++++++++-- internal/tui/theme_select_test.go | 113 +++++++++++++++--------------- 3 files changed, 101 insertions(+), 60 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index e462bcfca..42a09356d 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -11,6 +11,11 @@ the active theme and the registered names without opening the picker. ## Dune (`dune`) +A warm sand-and-cream palette with charcoal ink, soft amber accent, and +quantization-safe diff bands. + +## Dune Dark (`dune-dark`) + A dark, colorblind-safe palette matching Claude Code's daltonized dark mode: near-black surface, white ink, and a deuteranopia-adjusted brand-orange accent. diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 248272c5c..62f2b5066 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -433,14 +433,46 @@ var solarizedLightPalette = palette{ cardPerm: "#c4ae63", } -// dunePalette is a dark theme based on Claude Code's colorblind-friendly +// dunePalette is a warm sand-and-cream color scheme: sand/cream surface, +// charcoal ink, and a soft amber accent. +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) + green: "#38572a", + red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling + amber: "#6d4600", + blue: "#2f5680", + gitAdd: "#38572a", + gitDel: "#963328", + 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", + delInk: "#5c1810", + onAccent: "#fdf6ea", + cardRun: "#b08a4a", + cardErr: "#b57560", + cardPerm: "#c2a04a", +} + +// duneDarkPalette 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{ +var duneDarkPalette = palette{ panel: "#0e0e10", // Claude Code dark canvas — near-black promptBg: "#262626", // submitted user-prompt bubble line: "#242429", // borders/separators @@ -454,8 +486,8 @@ var dunePalette = palette{ 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", + gitAdd: "#5eccfa", // daltonized green for colorblind visibility + gitDel: "#ff6666", // daltonized red 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 @@ -496,9 +528,10 @@ 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: "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}, } // 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 c6a7b0c04..21ec08a34 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -275,18 +275,26 @@ 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 dark") + if dune.IsDark { + 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 +324,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 @@ -428,55 +436,50 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { 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}, - {"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)) + for _, themeName := range []string{"dune", "dune-dark"} { + pal := palettes[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)) } - } - // 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)) - } - 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)) + 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)) + } } } From d17e03102cf91d1d523748586ba1526f6f4a9533 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:32:37 -0400 Subject: [PATCH 09/17] fix(tui): make Dune Dark diffs readable on 16-color terminals Pick ANSI-safe add/delete pairs, assert the real 16-color conversion path, and list dune-dark in public theme inventories and changelog. --- CHANGELOG.md | 8 ++-- README.md | 2 +- README_ZH.md | 2 +- internal/tui/theme_palettes.go | 33 ++++++++------ internal/tui/theme_select_test.go | 75 +++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 19 deletions(-) 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/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 62f2b5066..0c07b1450 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -469,9 +469,16 @@ var dunePalette = palette{ // (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. +// 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 @@ -480,22 +487,22 @@ var duneDarkPalette = palette{ 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 + 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. On 16-color (bright cyan on green) the ratio is ~4.10:1 — short of AA but the best cyan that still sits under faint in the ramp 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) + 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: "#99ccff", // permission rgb(153,204,255) - gitAdd: "#5eccfa", // daltonized green for colorblind visibility - gitDel: "#ff6666", // daltonized red - 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) + 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", 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", + 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", cardRun: "#3399ff", // success blue cardErr: "#ff6666", // error diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 21ec08a34..069cd6043 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 { @@ -528,6 +529,80 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } } +// 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) { + var pal palette + found := false + for _, entry := range themeRegistry { + if entry.Name == "dune-dark" { + pal = entry.Palette + found = true + break + } + } + if !found { + t.Fatal("theme 'dune-dark' is not registered") + } + q := func(hexColor string) string { return ansi16Hex(t, hexColor) } + + // Diff sign text and changed-word text: the pairs users actually read on + // add/del rows under 16-color. Prior values were bright-blue-on-green + // (1.67:1) and bright-red-on-maroon (2.74:1). + 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 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)) + } + } + // Line numbers on the add band: bright cyan on ANSI green lands at ~4.10:1. + // That is the best cyan still under faint in the gray ramp; require it stay + // above 4.0 so a further regression is still caught. + if r := wcagRatio(t, q(pal.faintest), q(pal.addBg)); r < 4.0 { + t.Errorf("dune-dark: faintest on addBg = %.2f < 4.0 after ANSI 16-color conversion (%s on %s)", + r, q(pal.faintest), q(pal.addBg)) + } + + // 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)) + } +} + func mustR(t *testing.T, hex string) uint32 { t.Helper() r, _, _, _ := lipgloss.Color(hex).RGBA() From b27b21e632d0210655a19e1c6c548ded20515d54 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:38:50 -0400 Subject: [PATCH 10/17] fix(tui): meet AA for Dune Dark ANSI-16 add gutters Raise the Dune Dark gray ramp so faintest maps to a light ANSI/xterm gray that clears WCAG AA on the green add band, and require 4.5:1 in the 16-color audit instead of a 4.0 floor. --- internal/tui/theme_palettes.go | 10 +++++++--- internal/tui/theme_select_test.go | 12 +++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 0c07b1450..d1dfe495d 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -485,9 +485,13 @@ var duneDarkPalette = palette{ 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. On 16-color (bright cyan on green) the ratio is ~4.10:1 — short of AA but the best cyan that still sits under faint in the ramp + // 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) accent: "#ff9628", // brand/claude rgb(255,150,40), adjusted for deuteranopia 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 diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 069cd6043..4ae23420c 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -561,11 +561,16 @@ func TestDuneDarkANSI16Contrast(t *testing.T) { // Diff sign text and changed-word text: the pairs users actually read on // add/del rows under 16-color. Prior values were bright-blue-on-green // (1.67:1) and bright-red-on-maroon (2.74:1). + // 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 { @@ -573,13 +578,6 @@ func TestDuneDarkANSI16Contrast(t *testing.T) { pair.name, r, q(pair.fg), q(pair.bg)) } } - // Line numbers on the add band: bright cyan on ANSI green lands at ~4.10:1. - // That is the best cyan still under faint in the gray ramp; require it stay - // above 4.0 so a further regression is still caught. - if r := wcagRatio(t, q(pal.faintest), q(pal.addBg)); r < 4.0 { - t.Errorf("dune-dark: faintest on addBg = %.2f < 4.0 after ANSI 16-color conversion (%s on %s)", - r, q(pal.faintest), q(pal.addBg)) - } // Selected-row affordances that share the cool success/permission tokens. for _, pair := range []struct{ name, fg, bg string }{ From c80e8b9dadd792bc84c466a9222941df0d7f35ff Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:21 -0400 Subject: [PATCH 11/17] fix(tui): gofmt Dune Dark and keep selBg distinct under ANSI-16 Format theme_palettes.go for the required CI gate. Map selection to a deep indigo (navy under 16-color) and warm the accent toward sand-gold so the full-row highlight and foreground tokens clear AA on TERM=xterm. --- internal/tui/theme_palettes.go | 37 ++++++++++++++++++------------- internal/tui/theme_select_test.go | 10 +++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index d1dfe495d..913666801 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -480,19 +480,21 @@ var dunePalette = palette{ // 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", + 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) - accent: "#ff9628", // brand/claude rgb(255,150,40), adjusted for deuteranopia + 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) @@ -504,13 +506,16 @@ var duneDarkPalette = palette{ 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); 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", - cardRun: "#3399ff", // success blue - cardErr: "#ff6666", // error - cardPerm: "#ffcc00", // warning + // 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", + cardRun: "#3399ff", // success blue + cardErr: "#ff6666", // error + cardPerm: "#ffcc00", // warning } // themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 4ae23420c..8c8d94c57 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -579,6 +579,16 @@ func TestDuneDarkANSI16Contrast(t *testing.T) { } } + // 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}, From c961ada2e1f357ef5a984eb4b5418ac7bd972d6e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:12:22 -0400 Subject: [PATCH 12/17] fix(tui): audit Dune Dark status borders across every color profile Swap cardRun to #cceeff (was 2.44:1 on panel under ANSI-16 conversion, below the 3:1 WCAG 1.4.11 non-text threshold) and cardPerm to #fff200 (was collapsing to the same ANSI red as cardErr, losing the permission-state identity under 16-color). Add regression coverage for cardRun/cardErr/cardPerm against panel (and cardPerm against permBg) in truecolor, xterm-256, and real ANSI-16 conversion, plus collapse checks so running/error/permission stay three distinct states after quantization. --- internal/tui/theme_palettes.go | 14 ++++++-- internal/tui/theme_select_test.go | 59 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 913666801..761842d9f 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -513,9 +513,17 @@ var duneDarkPalette = palette{ 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", - cardRun: "#3399ff", // success blue - cardErr: "#ff6666", // error - cardPerm: "#ffcc00", // warning + // 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 diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 8c8d94c57..18a2e2991 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -370,6 +370,22 @@ 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. + 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) + } + } + } } } @@ -527,6 +543,28 @@ 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 := palettes["dune-dark"] + 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 — @@ -609,6 +647,27 @@ func TestDuneDarkANSI16Contrast(t *testing.T) { 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 { From f7fd778b385671f7e641513b00de315620888b5f Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 14:34:38 -0400 Subject: [PATCH 13/17] docs(tui): narrow Dune Dark accessibility claims Describe the palette as colorblind-friendly and inspired by Claude Code's daltonized dark palette, rather than claiming colorblind-safe parity with Claude Code dark mode. Align the theme_palettes comment the same way. --- docs/THEMES.md | 2 +- internal/tui/theme_palettes.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index 42a09356d..48e12e8bb 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -16,7 +16,7 @@ quantization-safe diff bands. ## Dune Dark (`dune-dark`) -A dark, colorblind-safe palette matching Claude Code's daltonized dark mode: +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. diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 761842d9f..3597830ba 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -465,8 +465,8 @@ var dunePalette = palette{ cardPerm: "#c2a04a", } -// duneDarkPalette is a dark theme based on Claude Code's colorblind-friendly -// (daltonized) dark mode. The base surface is near-black (Claude Code's true +// 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, From 1fc71c285b2cc538f99641760ae9d2782937e892 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 11 Aug 2026 16:41:32 -0400 Subject: [PATCH 14/17] Fix ANSI-256 theme test conversion Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> --- internal/tui/theme_select_test.go | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 18a2e2991..dc64f767d 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -400,33 +400,12 @@ 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 From 92b4538c3597be4ac7175770bd82f0fab654ae3c Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 11 Aug 2026 17:11:35 -0400 Subject: [PATCH 15/17] fix(tui): keep Dune accent AA after ANSI-256 quantization The production colorprofile.ANSI256 conversion (HSLuv distance) maps the previous accent to xterm #5f5f00, which drops accent-on-selBg contrast to 4.47:1. Darken the accent so it quantizes to #444444 instead, restoring 6.47:1 on the quantized selBg. The test helper now exercises the real production conversion path. Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> --- internal/tui/theme_palettes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 3597830ba..34299f2d0 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -444,7 +444,7 @@ 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) + 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 amber: "#6d4600", From fa60d77f7f58ea08e5eb5976e941b35d10029d06 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Sun, 16 Aug 2026 02:49:12 -0400 Subject: [PATCH 16/17] Clarify Dune theme docs and drop a stale onSel comment. Match THEMES.md to the tests it describes: registry-wide word-span contrast versus per-palette quantized add/del identity. Drop the leftover onSel wording from the ANSI-256 comment. Refs #713 Co-Authored-By: cairn-code --- docs/THEMES.md | 20 +++++++++++--------- internal/tui/theme_select_test.go | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index 48e12e8bb..6b70b5dcc 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -34,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_select_test.go b/internal/tui/theme_select_test.go index dc64f767d..ab1e697e5 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -411,7 +411,7 @@ func xterm256Hex(t *testing.T, hexColor string) string { // 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 +// 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 From 2061b69644b865ae854c2f8fdf1974a789ceeae6 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Sun, 16 Aug 2026 18:56:12 -0400 Subject: [PATCH 17/17] Fail fast when theme tests look up missing registry names. A missing palette key currently produces empty hex and a "bad hex" failure instead of naming the unregistered theme. Check the map lookup, reuse lookupTheme for Dune Dark's 16-color test, and note why light Dune stays out of the status-card border audit. Refs #841 --- internal/tui/theme_select_test.go | 33 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index ab1e697e5..c5fe0fb6e 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -374,6 +374,8 @@ func TestExtendedThemeContrastInvariants(t *testing.T) { // 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}, @@ -433,7 +435,10 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } for _, themeName := range []string{"dune", "dune-dark"} { - pal := palettes[themeName] + 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)) } @@ -479,7 +484,10 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { } } - neon := palettes["neon"] + 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)) @@ -527,7 +535,10 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { // 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 := palettes["dune-dark"] + 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}, @@ -561,23 +572,13 @@ func ansi16Hex(t *testing.T, hexColor string) string { // 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) { - var pal palette - found := false - for _, entry := range themeRegistry { - if entry.Name == "dune-dark" { - pal = entry.Palette - found = true - break - } - } - if !found { + 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 and changed-word text: the pairs users actually read on - // add/del rows under 16-color. Prior values were bright-blue-on-green - // (1.67:1) and bright-red-on-maroon (2.74:1). // 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