detached b6dc9ad

fix(theme): strengthen active grayscale session contrast (#316)

* fix(theme): strengthen active grayscale session contrast

* fix(theme): preserve overlay contrast and unify active tab surfaces

* fix(prompt): retain reduced-color selection emphasis

* docs(prompt): explain reduced-color selection emphasis
commit
b6dc9ad6074b32aefda5d949f88fb1c3717e8ad1
author
bnema · · 2026-10-05 17:17 UTC
committer
GitHub · · 2026-10-05 17:17 UTC
parent
93f7e39
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +61 -8
M internal/usecase/daemon/render_pipeline.go +6 -1
@@ -335,7 +335,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
335335 modal := promptModalFor(snap.promptModel.Title())
336336 presentation := modal.Resolve(size)
337337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
338- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
338+ selection := styles.Selection
339+ // Reduced copy selection is plain reverse; prompts retain bold emphasis.
340+ if styles.Reduced {
341+ selection = styles.SurfaceActive
342+ }
343+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
339344 }
340345 state.cursor.hiddenByOverlay = o.active()
341346 }
@@ -335,7 +335,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
335 modal := promptModalFor(snap.promptModel.Title())335 modal := promptModalFor(snap.promptModel.Title())
336 presentation := modal.Resolve(size)336 presentation := modal.Resolve(size)
337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}337 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
338- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})338+ selection := styles.Selection
339+ // Reduced copy selection is plain reverse; prompts retain bold emphasis.
340+ if styles.Reduced {
341+ selection = styles.SurfaceActive
342+ }
343+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
339 }344 }
340 state.cursor.hiddenByOverlay = o.active()345 state.cursor.hiddenByOverlay = o.active()
341 }346 }
M internal/usecase/daemon/theme_integration_test.go +2 -2
@@ -161,12 +161,12 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
161161 {
162162 name: "forced dark",
163163 config: domain.Config{Theme: domain.ThemeDark},
164- want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
164+ want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;24;24;24;48;2;216;216;216mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165165 },
166166 {
167167 name: "forced light",
168168 config: domain.Config{Theme: domain.ThemeLight},
169- want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
169+ want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;248;248;248;48;2;32;32;32mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170170 },
171171 }
172172 for _, tt := range tests {
@@ -161,12 +161,12 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
161 {161 {
162 name: "forced dark",162 name: "forced dark",
163 config: domain.Config{Theme: domain.ThemeDark},163 config: domain.Config{Theme: domain.ThemeDark},
164- want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m",164+ want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;24;24;24;48;2;216;216;216mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165 },165 },
166 {166 {
167 name: "forced light",167 name: "forced light",
168 config: domain.Config{Theme: domain.ThemeLight},168 config: domain.Config{Theme: domain.ThemeLight},
169- want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m",169+ want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;248;248;248;48;2;32;32;32mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170 },170 },
171 }171 }
172 for _, tt := range tests {172 for _, tt := range tests {
A internal/usecase/theme/neutral_session_test.go +40 -0
@@ -0,0 +1,40 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNeutralSessionSurfaceHierarchy(t *testing.T) {
11+ black := renderer.RGB{}
12+ white := renderer.RGB{R: 255, G: 255, B: 255}
13+ for _, tt := range []struct {
14+ name string
15+ foreground, background renderer.RGB
16+ }{
17+ {name: "dark", foreground: white, background: black},
18+ {name: "light", foreground: black, background: white},
19+ } {
20+ t.Run(tt.name, func(t *testing.T) {
21+ theme := Theme{Known: true, Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true}
22+ styles := neutralStyles(theme)
23+ require.Equal(t, tt.foreground, styles.SurfaceActive.BackgroundRGB)
24+ require.Equal(t, tt.background, styles.SurfaceActive.ForegroundRGB)
25+ require.Equal(t, styles.TabActive.BackgroundRGB, styles.SurfaceActive.BackgroundRGB)
26+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, styles.TabActiveTitle.BackgroundRGB)
27+ soft := Blend(tt.background, tt.foreground, neutralAccentBlend)
28+ for _, selection := range []renderer.Style{styles.PickerSelection, styles.Selection, styles.SearchSelection} {
29+ require.Equal(t, soft, selection.BackgroundRGB)
30+ }
31+ require.Equal(t, Blend(styles.SurfaceBar.BackgroundRGB, tt.foreground, 0.5), styles.MRUStyle(0, 3).BackgroundRGB)
32+ for count := 1; count <= 9; count++ {
33+ for index := range count {
34+ want := Blend(styles.SurfaceActive.BackgroundRGB, styles.SurfaceBar.BackgroundRGB, 1-mruWeight(index, count))
35+ require.Equal(t, want, styles.MRUStyle(index, count).BackgroundRGB)
36+ }
37+ }
38+ })
39+ }
40+}
@@ -0,0 +1,40 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNeutralSessionSurfaceHierarchy(t *testing.T) {
11+ black := renderer.RGB{}
12+ white := renderer.RGB{R: 255, G: 255, B: 255}
13+ for _, tt := range []struct {
14+ name string
15+ foreground, background renderer.RGB
16+ }{
17+ {name: "dark", foreground: white, background: black},
18+ {name: "light", foreground: black, background: white},
19+ } {
20+ t.Run(tt.name, func(t *testing.T) {
21+ theme := Theme{Known: true, Foreground: tt.foreground, Background: tt.background, HasFG: true, HasBG: true}
22+ styles := neutralStyles(theme)
23+ require.Equal(t, tt.foreground, styles.SurfaceActive.BackgroundRGB)
24+ require.Equal(t, tt.background, styles.SurfaceActive.ForegroundRGB)
25+ require.Equal(t, styles.TabActive.BackgroundRGB, styles.SurfaceActive.BackgroundRGB)
26+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, styles.TabActiveTitle.BackgroundRGB)
27+ soft := Blend(tt.background, tt.foreground, neutralAccentBlend)
28+ for _, selection := range []renderer.Style{styles.PickerSelection, styles.Selection, styles.SearchSelection} {
29+ require.Equal(t, soft, selection.BackgroundRGB)
30+ }
31+ require.Equal(t, Blend(styles.SurfaceBar.BackgroundRGB, tt.foreground, 0.5), styles.MRUStyle(0, 3).BackgroundRGB)
32+ for count := 1; count <= 9; count++ {
33+ for index := range count {
34+ want := Blend(styles.SurfaceActive.BackgroundRGB, styles.SurfaceBar.BackgroundRGB, 1-mruWeight(index, count))
35+ require.Equal(t, want, styles.MRUStyle(index, count).BackgroundRGB)
36+ }
37+ }
38+ })
39+ }
40+}
M internal/usecase/theme/phase3_review_test.go +3 -1
@@ -120,8 +120,10 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
120120 // PickerSeparator is now a semantic role with the SurfaceBar
121121 // background; it was never a compatibility alias.
122122 delete(want, "PickerSeparator")
123- // Active neutral tab names use a high-contrast history anchor.
123+ // Active neutral sessions and tab names share the history anchor.
124+ want["Accent"] = rgbSurface(tt.theme.Background, tt.theme.Foreground)
124125 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
126+ want["TabTitleActive"] = MutedVariantStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
125127 for name, style := range want {
126128 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
127129 }
@@ -120,8 +120,10 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
120 // PickerSeparator is now a semantic role with the SurfaceBar120 // PickerSeparator is now a semantic role with the SurfaceBar
121 // background; it was never a compatibility alias.121 // background; it was never a compatibility alias.
122 delete(want, "PickerSeparator")122 delete(want, "PickerSeparator")
123- // Active neutral tab names use a high-contrast history anchor.123+ // Active neutral sessions and tab names share the history anchor.
124+ want["Accent"] = rgbSurface(tt.theme.Background, tt.theme.Foreground)
124 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)125 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
126+ want["TabTitleActive"] = MutedVariantStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
125 for name, style := range want {127 for name, style := range want {
126 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)128 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
127 }129 }
M internal/usecase/theme/resolved_styles.go +9 -3
@@ -117,8 +117,8 @@ func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
117117 return style
118118 }
119119
120-// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
121-// is used for palette-off, forced themes, incomplete defaults, and RGB-less
120+// neutralStyles uses full-strength session surfaces and softer overlay selections.
121+// It is used for palette-off, forced themes, incomplete defaults, and RGB-less
122122 // indexed fallbacks; it never performs a palette slot lookup.
123123 func neutralStyles(t Theme) Styles {
124124 status := renderer.DefaultStyle()
@@ -162,8 +162,14 @@ func neutralStyles(t Theme) Styles {
162162 styles.PaletteDesc = legacyMutedText(t)
163163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
164164 if usable(t) {
165- styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)
165+ // Sessions share the full-strength neutral endpoint with active tabs;
166+ // overlay selections retain their softer neutral surface.
167+ styles.SurfaceActive = rgbSurface(t.Background, t.Foreground)
168+ styles.Accent = styles.SurfaceActive
169+ styles.TabActive = EmphasisStyle(styles.SurfaceActive, t)
166170 styles.TabNameActive = styles.TabActive
171+ styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t)
172+ styles.TabTitleActive = styles.TabActiveTitle
167173 // Without a palette accent the history uses the same mruWeight
168174 // placement as MRUStyle, between the active tab and the bar.
169175 for count := 1; count <= len(styles.mruStyles); count++ {
@@ -117,8 +117,8 @@ func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
117 return style117 return style
118 }118 }
119 119
120-// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It120+// neutralStyles uses full-strength session surfaces and softer overlay selections.
121-// is used for palette-off, forced themes, incomplete defaults, and RGB-less121+// It is used for palette-off, forced themes, incomplete defaults, and RGB-less
122 // indexed fallbacks; it never performs a palette slot lookup.122 // indexed fallbacks; it never performs a palette slot lookup.
123 func neutralStyles(t Theme) Styles {123 func neutralStyles(t Theme) Styles {
124 status := renderer.DefaultStyle()124 status := renderer.DefaultStyle()
@@ -162,8 +162,14 @@ func neutralStyles(t Theme) Styles {
162 styles.PaletteDesc = legacyMutedText(t)162 styles.PaletteDesc = legacyMutedText(t)
163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})163 styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
164 if usable(t) {164 if usable(t) {
165- styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)165+ // Sessions share the full-strength neutral endpoint with active tabs;
166+ // overlay selections retain their softer neutral surface.
167+ styles.SurfaceActive = rgbSurface(t.Background, t.Foreground)
168+ styles.Accent = styles.SurfaceActive
169+ styles.TabActive = EmphasisStyle(styles.SurfaceActive, t)
166 styles.TabNameActive = styles.TabActive170 styles.TabNameActive = styles.TabActive
171+ styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t)
172+ styles.TabTitleActive = styles.TabActiveTitle
167 // Without a palette accent the history uses the same mruWeight173 // Without a palette accent the history uses the same mruWeight
168 // placement as MRUStyle, between the active tab and the bar.174 // placement as MRUStyle, between the active tab and the bar.
169 for count := 1; count <= len(styles.mruStyles); count++ {175 for count := 1; count <= len(styles.mruStyles); count++ {
M internal/usecase/theme/theme_test.go +1 -1
@@ -592,7 +592,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
592592 t.Fatalf("status themed=%+v", status)
593593 }
594594 accent := AccentStyle(theme)
595- if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {
595+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != theme.Foreground || accent.ForegroundRGB != theme.Background {
596596 t.Fatalf("accent themed=%+v", accent)
597597 }
598598 border := BorderStyle(theme)
@@ -592,7 +592,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
592 t.Fatalf("status themed=%+v", status)592 t.Fatalf("status themed=%+v", status)
593 }593 }
594 accent := AccentStyle(theme)594 accent := AccentStyle(theme)
595- if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {595+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != theme.Foreground || accent.ForegroundRGB != theme.Background {
596 t.Fatalf("accent themed=%+v", accent)596 t.Fatalf("accent themed=%+v", accent)
597 }597 }
598 border := BorderStyle(theme)598 border := BorderStyle(theme)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close