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 Unverified
B5690EEEBB952194The 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 | |||
| 335 | 335 | modal := promptModalFor(snap.promptModel.Title()) | |
| 336 | 336 | presentation := modal.Resolve(size) | |
| 337 | 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 | 345 | state.cursor.hiddenByOverlay = o.active() | |
| 341 | 346 | } | |
| @@ -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) { | |||
| 161 | 161 | { | |
| 162 | 162 | name: "forced dark", | |
| 163 | 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 | 167 | name: "forced light", | |
| 168 | 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 | 172 | 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) { | |||
| 120 | 120 | // PickerSeparator is now a semantic role with the SurfaceBar | |
| 121 | 121 | // background; it was never a compatibility alias. | |
| 122 | 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 | 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 | 127 | for name, style := range want { | |
| 126 | 128 | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |
| 127 | 129 | } | |
| @@ -120,8 +120,10 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||||
| 120 | // PickerSeparator is now a semantic role with the SurfaceBar | 120 | // 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 { | |||
| 117 | 117 | return style | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 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 | |
| 122 | 122 | // indexed fallbacks; it never performs a palette slot lookup. | |
| 123 | 123 | func neutralStyles(t Theme) Styles { | |
| 124 | 124 | status := renderer.DefaultStyle() | |
| @@ -162,8 +162,14 @@ func neutralStyles(t Theme) Styles { | |||
| 162 | 162 | styles.PaletteDesc = legacyMutedText(t) | |
| 163 | 163 | styles = withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| 164 | 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 | 170 | styles.TabNameActive = styles.TabActive | |
| 171 | + | styles.TabActiveTitle = MutedVariantStyle(styles.SurfaceActive, t) | |
| 172 | + | styles.TabTitleActive = styles.TabActiveTitle | |
| 167 | 173 | // Without a palette accent the history uses the same mruWeight | |
| 168 | 174 | // placement as MRUStyle, between the active tab and the bar. | |
| 169 | 175 | for count := 1; count <= len(styles.mruStyles); count++ { | |
| @@ -117,8 +117,8 @@ func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |||||
| 117 | return style | 117 | return style | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | - | // neutralStyles deliberately preserves the pre-accent neutral hierarchy. It | 120 | + | // neutralStyles uses full-strength session surfaces and softer overlay selections. |
| 121 | - | // is used for palette-off, forced themes, incomplete defaults, and RGB-less | 121 | + | // 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.TabActive | 170 | 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 mruWeight | 173 | // 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) { | |||
| 592 | 592 | t.Fatalf("status themed=%+v", status) | |
| 593 | 593 | } | |
| 594 | 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 | 596 | t.Fatalf("accent themed=%+v", accent) | |
| 597 | 597 | } | |
| 598 | 598 | 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) | ||