fix(theme): restore indexed accent fallbacks
- commit
- ffd16e45e2845384ee6b2ebf637dc81b744a6b1a
- author
- brice · · 2026-07-18 08:05 UTC
- parent
- b85f942
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +230 -21
M internal/usecase/theme/accent_resolve.go +31 -6
| @@ -49,17 +49,19 @@ func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { | |||
| 49 | 49 | ||
| 50 | 50 | func resolveExplicitAccent(t Theme, slot uint8) Accent { | |
| 51 | 51 | if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 52 | - | return Accent{} | |
| 52 | + | // An explicit policy is a strict override. Even without OSC RGB for its | |
| 53 | + | // slot, it may still safely decorate foregrounds and borders by index. | |
| 54 | + | return indexedAccent(slot) | |
| 53 | 55 | } | |
| 54 | 56 | return resolvedAccent(t, t.Palette[slot], slot) | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | 59 | func resolveAutoAccent(t Theme) Accent { | |
| 58 | 60 | // Background is necessary to reject colors that merely alias the terminal | |
| 59 | - | // surface. Foreground and truecolor are deliberately not required here: | |
| 60 | - | // the winning slot remains useful as an indexed foreground-only fallback. | |
| 61 | + | // surface. Without it, retain the pre-accent scheme-aware ANSI blue | |
| 62 | + | // foreground fallback rather than inventing an RGB background. | |
| 61 | 63 | if !t.HasBG { | |
| 62 | - | return Accent{} | |
| 64 | + | return schemeIndexedBlue(t) | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | 67 | var colors [len(accentCandidateSlots)]oklab | |
| @@ -75,7 +77,7 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 75 | 77 | } | |
| 76 | 78 | } | |
| 77 | 79 | if eligible == 0 { | |
| 78 | - | return Accent{} | |
| 80 | + | return fallbackAutoAccent(t, background) | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | 83 | var adjacent [len(accentCandidateSlots)]uint16 | |
| @@ -143,6 +145,10 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 143 | 145 | return resolvedAccent(t, t.Palette[slot], slot) | |
| 144 | 146 | } | |
| 145 | 147 | ||
| 148 | + | return fallbackAutoAccent(t, background) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func fallbackAutoAccent(t Theme, background oklab) Accent { | |
| 146 | 152 | for _, slot := range [...]uint8{4, 12} { | |
| 147 | 153 | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 148 | 154 | continue | |
| @@ -152,7 +158,26 @@ func resolveAutoAccent(t Theme) Accent { | |||
| 152 | 158 | return resolvedAccent(t, t.Palette[slot], slot) | |
| 153 | 159 | } | |
| 154 | 160 | } | |
| 155 | - | return Accent{} | |
| 161 | + | if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 { | |
| 162 | + | // A terminal-reported blue that fails eligibility deliberately does not | |
| 163 | + | // become an unvalidated indexed fallback. | |
| 164 | + | return Accent{} | |
| 165 | + | } | |
| 166 | + | return schemeIndexedBlue(t) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func schemeIndexedBlue(t Theme) Accent { | |
| 170 | + | if !t.SchemeKnown { | |
| 171 | + | return Accent{} | |
| 172 | + | } | |
| 173 | + | if t.Light { | |
| 174 | + | return indexedAccent(4) | |
| 175 | + | } | |
| 176 | + | return indexedAccent(12) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func indexedAccent(slot uint8) Accent { | |
| 180 | + | return Accent{Slot: slot, IndexedOnly: true} | |
| 156 | 181 | } | |
| 157 | 182 | ||
| 158 | 183 | func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) { | |
| @@ -49,17 +49,19 @@ func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { | |||||
| 49 | 49 | ||||
| 50 | func resolveExplicitAccent(t Theme, slot uint8) Accent { | 50 | func resolveExplicitAccent(t Theme, slot uint8) Accent { | ||
| 51 | if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | 51 | if slot >= uint8(len(t.Palette)) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | ||
| 52 | - | return Accent{} | 52 | + | // An explicit policy is a strict override. Even without OSC RGB for its |
| 53 | + | // slot, it may still safely decorate foregrounds and borders by index. | |||
| 54 | + | return indexedAccent(slot) | |||
| 53 | } | 55 | } | ||
| 54 | return resolvedAccent(t, t.Palette[slot], slot) | 56 | return resolvedAccent(t, t.Palette[slot], slot) | ||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 57 | func resolveAutoAccent(t Theme) Accent { | 59 | func resolveAutoAccent(t Theme) Accent { | ||
| 58 | // Background is necessary to reject colors that merely alias the terminal | 60 | // Background is necessary to reject colors that merely alias the terminal | ||
| 59 | - | // surface. Foreground and truecolor are deliberately not required here: | 61 | + | // surface. Without it, retain the pre-accent scheme-aware ANSI blue |
| 60 | - | // the winning slot remains useful as an indexed foreground-only fallback. | 62 | + | // foreground fallback rather than inventing an RGB background. |
| 61 | if !t.HasBG { | 63 | if !t.HasBG { | ||
| 62 | - | return Accent{} | 64 | + | return schemeIndexedBlue(t) |
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | var colors [len(accentCandidateSlots)]oklab | 67 | var colors [len(accentCandidateSlots)]oklab | ||
| @@ -75,7 +77,7 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 75 | } | 77 | } | ||
| 76 | } | 78 | } | ||
| 77 | if eligible == 0 { | 79 | if eligible == 0 { | ||
| 78 | - | return Accent{} | 80 | + | return fallbackAutoAccent(t, background) |
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 81 | var adjacent [len(accentCandidateSlots)]uint16 | 83 | var adjacent [len(accentCandidateSlots)]uint16 | ||
| @@ -143,6 +145,10 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 143 | return resolvedAccent(t, t.Palette[slot], slot) | 145 | return resolvedAccent(t, t.Palette[slot], slot) | ||
| 144 | } | 146 | } | ||
| 145 | 147 | ||||
| 148 | + | return fallbackAutoAccent(t, background) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func fallbackAutoAccent(t Theme, background oklab) Accent { | |||
| 146 | for _, slot := range [...]uint8{4, 12} { | 152 | for _, slot := range [...]uint8{4, 12} { | ||
| 147 | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | 153 | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | ||
| 148 | continue | 154 | continue | ||
| @@ -152,7 +158,26 @@ func resolveAutoAccent(t Theme) Accent { | |||||
| 152 | return resolvedAccent(t, t.Palette[slot], slot) | 158 | return resolvedAccent(t, t.Palette[slot], slot) | ||
| 153 | } | 159 | } | ||
| 154 | } | 160 | } | ||
| 155 | - | return Accent{} | 161 | + | if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 { |
| 162 | + | // A terminal-reported blue that fails eligibility deliberately does not | |||
| 163 | + | // become an unvalidated indexed fallback. | |||
| 164 | + | return Accent{} | |||
| 165 | + | } | |||
| 166 | + | return schemeIndexedBlue(t) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func schemeIndexedBlue(t Theme) Accent { | |||
| 170 | + | if !t.SchemeKnown { | |||
| 171 | + | return Accent{} | |||
| 172 | + | } | |||
| 173 | + | if t.Light { | |||
| 174 | + | return indexedAccent(4) | |||
| 175 | + | } | |||
| 176 | + | return indexedAccent(12) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func indexedAccent(slot uint8) Accent { | |||
| 180 | + | return Accent{Slot: slot, IndexedOnly: true} | |||
| 156 | } | 181 | } | ||
| 157 | 182 | ||||
| 158 | func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) { | 183 | func finalizeAccentGroup(group *accentGroup, colors [len(accentCandidateSlots)]oklab) { | ||
M internal/usecase/theme/accent_resolve_test.go +5 -2
| @@ -18,8 +18,10 @@ func TestResolveAccentThresholds(t *testing.T) { | |||
| 18 | 18 | }{ | |
| 19 | 19 | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | |
| 20 | 20 | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | |
| 21 | + | {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true}, | |
| 21 | 22 | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | |
| 22 | 23 | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |
| 24 | + | {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |
| 23 | 25 | } | |
| 24 | 26 | for _, tt := range tests { | |
| 25 | 27 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -28,6 +30,7 @@ func TestResolveAccentThresholds(t *testing.T) { | |||
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | a := oklab{} | |
| 33 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001})) | |
| 31 | 34 | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | |
| 32 | 35 | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | |
| 33 | 36 | } | |
| @@ -143,10 +146,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||
| 143 | 146 | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | |
| 144 | 147 | }, | |
| 145 | 148 | { | |
| 146 | - | name: "unknown explicit slot never substitutes fallback blue", | |
| 149 | + | name: "unknown explicit slot keeps its exact indexed decoration", | |
| 147 | 150 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 148 | 151 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | |
| 149 | - | want: Accent{}, | |
| 152 | + | want: Accent{Slot: 5, IndexedOnly: true}, | |
| 150 | 153 | }, | |
| 151 | 154 | { | |
| 152 | 155 | name: "explicit known slot is indexed only without truecolor prerequisites", | |
| @@ -18,8 +18,10 @@ func TestResolveAccentThresholds(t *testing.T) { | |||||
| 18 | }{ | 18 | }{ | ||
| 19 | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | 19 | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | ||
| 20 | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | 20 | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | ||
| 21 | + | {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true}, | |||
| 21 | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | 22 | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | ||
| 22 | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | 23 | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | ||
| 24 | + | {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |||
| 23 | } | 25 | } | ||
| 24 | for _, tt := range tests { | 26 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 27 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -28,6 +30,7 @@ func TestResolveAccentThresholds(t *testing.T) { | |||||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | a := oklab{} | 32 | a := oklab{} | ||
| 33 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001})) | |||
| 31 | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | 34 | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | ||
| 32 | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | 35 | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | ||
| 33 | } | 36 | } | ||
| @@ -143,10 +146,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||||
| 143 | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | 146 | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | ||
| 144 | }, | 147 | }, | ||
| 145 | { | 148 | { | ||
| 146 | - | name: "unknown explicit slot never substitutes fallback blue", | 149 | + | name: "unknown explicit slot keeps its exact indexed decoration", |
| 147 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | 150 | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | ||
| 148 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | 151 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | ||
| 149 | - | want: Accent{}, | 152 | + | want: Accent{Slot: 5, IndexedOnly: true}, |
| 150 | }, | 153 | }, | ||
| 151 | { | 154 | { | ||
| 152 | name: "explicit known slot is indexed only without truecolor prerequisites", | 155 | name: "explicit known slot is indexed only without truecolor prerequisites", | ||
A internal/usecase/theme/phase3_review_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |
| 12 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 13 | + | explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5} | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | theme Theme | |
| 17 | + | policy domain.ThemeAccent | |
| 18 | + | want Accent | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | name: "explicit unknown slot decorates only with its exact index", | |
| 22 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}), | |
| 23 | + | policy: explicit, | |
| 24 | + | want: Accent{Slot: 5, IndexedOnly: true}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "explicit known slot without truecolor is indexed only", | |
| 28 | + | theme: func() Theme { | |
| 29 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 30 | + | t.TrueColor = false | |
| 31 | + | return t | |
| 32 | + | }(), | |
| 33 | + | policy: explicit, | |
| 34 | + | want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true}, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | |
| 38 | + | theme: Theme{ | |
| 39 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 40 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true, | |
| 41 | + | }, | |
| 42 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 43 | + | want: Accent{Slot: 12, IndexedOnly: true}, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "automatic absent defaults keeps light scheme indexed blue fallback", | |
| 47 | + | theme: Theme{ | |
| 48 | + | TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true, | |
| 49 | + | }, | |
| 50 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 51 | + | want: Accent{Slot: 4, IndexedOnly: true}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "automatic absent palette and unknown scheme stays neutral", | |
| 55 | + | theme: Theme{TrueColor: true, Known: true, UsePalette: true}, | |
| 56 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 57 | + | want: Accent{}, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "palette off suppresses explicit indexed decoration", | |
| 61 | + | theme: func() Theme { | |
| 62 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 63 | + | t.UsePalette = false | |
| 64 | + | return t | |
| 65 | + | }(), | |
| 66 | + | policy: explicit, | |
| 67 | + | want: Accent{}, | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | for _, tt := range tests { | |
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |
| 73 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) { | |
| 79 | + | theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |
| 80 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}) | |
| 81 | + | require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent) | |
| 82 | + | ||
| 83 | + | for name, style := range legacyAndSemanticStyles(resolved.Styles) { | |
| 84 | + | require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name) | |
| 85 | + | } | |
| 86 | + | require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground) | |
| 87 | + | require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) { | |
| 91 | + | theme := rampTheme(false) | |
| 92 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 93 | + | theme.Palette[10] = theme.Palette[2] | |
| 94 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 95 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 96 | + | ||
| 97 | + | want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB) | |
| 98 | + | require.True(t, ok) | |
| 99 | + | require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB) | |
| 100 | + | require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |
| 104 | + | paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |
| 105 | + | paletteOff.UsePalette = false | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | theme Theme | |
| 109 | + | }{ | |
| 110 | + | {name: "palette off", theme: paletteOff}, | |
| 111 | + | {name: "forced dark", theme: BuiltinDark}, | |
| 112 | + | {name: "forced light", theme: BuiltinLight}, | |
| 113 | + | } | |
| 114 | + | for _, tt := range tests { | |
| 115 | + | t.Run(tt.name, func(t *testing.T) { | |
| 116 | + | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |
| 117 | + | want := prePhase3LegacyAliases(tt.theme) | |
| 118 | + | for name, style := range want { | |
| 119 | + | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func prePhase3LegacyAliases(t Theme) map[string]renderer.Style { | |
| 126 | + | status := renderer.DefaultStyle() | |
| 127 | + | accent := inverseStyle() | |
| 128 | + | border := renderer.DefaultStyle() | |
| 129 | + | muted := renderer.DefaultStyle() | |
| 130 | + | if usable(t) { | |
| 131 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 132 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 133 | + | border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 134 | + | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 135 | + | } | |
| 136 | + | selection := accent | |
| 137 | + | return map[string]renderer.Style{ | |
| 138 | + | "StatusBar": status, | |
| 139 | + | "Accent": accent, | |
| 140 | + | "Border": border, | |
| 141 | + | "Selection": selection, | |
| 142 | + | "CopyStatus": selection, | |
| 143 | + | "PaletteDesc": muted, | |
| 144 | + | "TabName": EmphasisStyle(status, t), | |
| 145 | + | "TabNameActive": EmphasisStyle(accent, t), | |
| 146 | + | "TabTitle": MutedVariantStyle(status, t), | |
| 147 | + | "TabTitleActive": MutedVariantStyle(accent, t), | |
| 148 | + | "PickerName": EmphasisStyle(renderer.DefaultStyle(), t), | |
| 149 | + | "PickerSelectionName": EmphasisStyle(selection, t), | |
| 150 | + | "PickerSelectionMuted": MutedVariantStyle(selection, t), | |
| 151 | + | "PickerSeparator": muted, | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style { | |
| 156 | + | return map[string]renderer.Style{ | |
| 157 | + | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | |
| 158 | + | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | |
| 159 | + | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | |
| 160 | + | "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | |
| 161 | + | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | |
| 162 | + | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | |
| 163 | + | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | |
| 164 | + | "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive, | |
| 165 | + | "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName, | |
| 166 | + | "PickerSelectionMuted": styles.PickerSelectionMuted, | |
| 167 | + | } | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||
| 12 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 13 | + | explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5} | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | theme Theme | |||
| 17 | + | policy domain.ThemeAccent | |||
| 18 | + | want Accent | |||
| 19 | + | }{ | |||
| 20 | + | { | |||
| 21 | + | name: "explicit unknown slot decorates only with its exact index", | |||
| 22 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}), | |||
| 23 | + | policy: explicit, | |||
| 24 | + | want: Accent{Slot: 5, IndexedOnly: true}, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "explicit known slot without truecolor is indexed only", | |||
| 28 | + | theme: func() Theme { | |||
| 29 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |||
| 30 | + | t.TrueColor = false | |||
| 31 | + | return t | |||
| 32 | + | }(), | |||
| 33 | + | policy: explicit, | |||
| 34 | + | want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true}, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | |||
| 38 | + | theme: Theme{ | |||
| 39 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |||
| 40 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true, | |||
| 41 | + | }, | |||
| 42 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |||
| 43 | + | want: Accent{Slot: 12, IndexedOnly: true}, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | name: "automatic absent defaults keeps light scheme indexed blue fallback", | |||
| 47 | + | theme: Theme{ | |||
| 48 | + | TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true, | |||
| 49 | + | }, | |||
| 50 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |||
| 51 | + | want: Accent{Slot: 4, IndexedOnly: true}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "automatic absent palette and unknown scheme stays neutral", | |||
| 55 | + | theme: Theme{TrueColor: true, Known: true, UsePalette: true}, | |||
| 56 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |||
| 57 | + | want: Accent{}, | |||
| 58 | + | }, | |||
| 59 | + | { | |||
| 60 | + | name: "palette off suppresses explicit indexed decoration", | |||
| 61 | + | theme: func() Theme { | |||
| 62 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |||
| 63 | + | t.UsePalette = false | |||
| 64 | + | return t | |||
| 65 | + | }(), | |||
| 66 | + | policy: explicit, | |||
| 67 | + | want: Accent{}, | |||
| 68 | + | }, | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | for _, tt := range tests { | |||
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 73 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) { | |||
| 79 | + | theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |||
| 80 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}) | |||
| 81 | + | require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent) | |||
| 82 | + | ||||
| 83 | + | for name, style := range legacyAndSemanticStyles(resolved.Styles) { | |||
| 84 | + | require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name) | |||
| 85 | + | } | |||
| 86 | + | require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground) | |||
| 87 | + | require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) { | |||
| 91 | + | theme := rampTheme(false) | |||
| 92 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 93 | + | theme.Palette[10] = theme.Palette[2] | |||
| 94 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |||
| 95 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 96 | + | ||||
| 97 | + | want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB) | |||
| 98 | + | require.True(t, ok) | |||
| 99 | + | require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB) | |||
| 100 | + | require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |||
| 104 | + | paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |||
| 105 | + | paletteOff.UsePalette = false | |||
| 106 | + | tests := []struct { | |||
| 107 | + | name string | |||
| 108 | + | theme Theme | |||
| 109 | + | }{ | |||
| 110 | + | {name: "palette off", theme: paletteOff}, | |||
| 111 | + | {name: "forced dark", theme: BuiltinDark}, | |||
| 112 | + | {name: "forced light", theme: BuiltinLight}, | |||
| 113 | + | } | |||
| 114 | + | for _, tt := range tests { | |||
| 115 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 116 | + | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |||
| 117 | + | want := prePhase3LegacyAliases(tt.theme) | |||
| 118 | + | for name, style := range want { | |||
| 119 | + | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func prePhase3LegacyAliases(t Theme) map[string]renderer.Style { | |||
| 126 | + | status := renderer.DefaultStyle() | |||
| 127 | + | accent := inverseStyle() | |||
| 128 | + | border := renderer.DefaultStyle() | |||
| 129 | + | muted := renderer.DefaultStyle() | |||
| 130 | + | if usable(t) { | |||
| 131 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |||
| 132 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |||
| 133 | + | border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |||
| 134 | + | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |||
| 135 | + | } | |||
| 136 | + | selection := accent | |||
| 137 | + | return map[string]renderer.Style{ | |||
| 138 | + | "StatusBar": status, | |||
| 139 | + | "Accent": accent, | |||
| 140 | + | "Border": border, | |||
| 141 | + | "Selection": selection, | |||
| 142 | + | "CopyStatus": selection, | |||
| 143 | + | "PaletteDesc": muted, | |||
| 144 | + | "TabName": EmphasisStyle(status, t), | |||
| 145 | + | "TabNameActive": EmphasisStyle(accent, t), | |||
| 146 | + | "TabTitle": MutedVariantStyle(status, t), | |||
| 147 | + | "TabTitleActive": MutedVariantStyle(accent, t), | |||
| 148 | + | "PickerName": EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 149 | + | "PickerSelectionName": EmphasisStyle(selection, t), | |||
| 150 | + | "PickerSelectionMuted": MutedVariantStyle(selection, t), | |||
| 151 | + | "PickerSeparator": muted, | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style { | |||
| 156 | + | return map[string]renderer.Style{ | |||
| 157 | + | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | |||
| 158 | + | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | |||
| 159 | + | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | |||
| 160 | + | "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | |||
| 161 | + | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | |||
| 162 | + | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | |||
| 163 | + | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | |||
| 164 | + | "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive, | |||
| 165 | + | "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName, | |||
| 166 | + | "PickerSelectionMuted": styles.PickerSelectionMuted, | |||
| 167 | + | } | |||
| 168 | + | } | |||
M internal/usecase/theme/resolved_styles.go +26 -13
| @@ -28,7 +28,7 @@ func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme { | |||
| 28 | 28 | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |
| 29 | 29 | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | |
| 30 | 30 | styles := neutralStyles(t) | |
| 31 | - | if accent.Known && accent.IndexedOnly { | |
| 31 | + | if accent.IndexedOnly { | |
| 32 | 32 | styles = indexedStyles(styles, accent.Slot) | |
| 33 | 33 | } | |
| 34 | 34 | return styles | |
| @@ -54,12 +54,14 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 54 | 54 | PickerBase: ramp.SurfaceBar, | |
| 55 | 55 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 56 | 56 | PickerDescription: barDescription, | |
| 57 | - | PickerSeparator: ramp.BorderMuted, | |
| 58 | - | PromptBase: ramp.SurfaceBar, | |
| 59 | - | CopyStatus: ramp.SurfaceBar, | |
| 60 | - | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 57 | + | // Separators are secondary text on the picker base surface, not | |
| 58 | + | // non-text borders; they therefore require the normal 4.5:1 contrast. | |
| 59 | + | PickerSeparator: barDescription, | |
| 60 | + | PromptBase: ramp.SurfaceBar, | |
| 61 | + | CopyStatus: ramp.SurfaceBar, | |
| 62 | + | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 61 | 63 | } | |
| 62 | - | return withLegacyAliases(styles) | |
| 64 | + | return withLegacyAliases(styles, t) | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | 67 | func secondarySurface(base renderer.Style) renderer.Style { | |
| @@ -77,16 +79,25 @@ func secondarySurface(base renderer.Style) renderer.Style { | |||
| 77 | 79 | func indexedStyles(styles Styles, slot uint8) Styles { | |
| 78 | 80 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | |
| 79 | 81 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |
| 82 | + | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | |
| 83 | + | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |
| 84 | + | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | |
| 80 | 85 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | |
| 86 | + | ||
| 87 | + | // Compatibility aliases historically received the scheme-aware indexed | |
| 88 | + | // blue fallback too. Keep the strict explicit index on each foreground | |
| 89 | + | // decoration without changing any neutral surface background. | |
| 81 | 90 | styles.Border = styles.BorderMuted | |
| 91 | + | styles.PaletteDesc = styles.PickerDescription | |
| 92 | + | styles.TabTitle = styles.TabInactiveTitle | |
| 93 | + | styles.TabTitleActive = styles.TabActiveTitle | |
| 94 | + | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | |
| 82 | 95 | return styles | |
| 83 | 96 | } | |
| 84 | 97 | ||
| 85 | 98 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |
| 86 | 99 | style.HasForegroundRGB = false | |
| 87 | 100 | style.Foreground = int(slot) | |
| 88 | - | style.HasBackgroundRGB = false | |
| 89 | - | style.Background = -1 | |
| 90 | 101 | return style | |
| 91 | 102 | } | |
| 92 | 103 | ||
| @@ -126,7 +137,7 @@ func neutralStyles(t Theme) Styles { | |||
| 126 | 137 | CopyStatus: accent, | |
| 127 | 138 | SearchSelection: accent, | |
| 128 | 139 | } | |
| 129 | - | return withLegacyAliases(styles) | |
| 140 | + | return withLegacyAliases(styles, t) | |
| 130 | 141 | } | |
| 131 | 142 | ||
| 132 | 143 | func neutralBorderStyle(t Theme) renderer.Style { | |
| @@ -136,7 +147,7 @@ func neutralBorderStyle(t Theme) renderer.Style { | |||
| 136 | 147 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 137 | 148 | } | |
| 138 | 149 | ||
| 139 | - | func withLegacyAliases(styles Styles) Styles { | |
| 150 | + | func withLegacyAliases(styles Styles, t Theme) Styles { | |
| 140 | 151 | styles.StatusBar = styles.SurfaceBar | |
| 141 | 152 | styles.Accent = styles.SurfaceActive | |
| 142 | 153 | styles.Border = styles.BorderMuted | |
| @@ -146,8 +157,10 @@ func withLegacyAliases(styles Styles) Styles { | |||
| 146 | 157 | styles.TabNameActive = styles.TabActive | |
| 147 | 158 | styles.TabTitle = styles.TabInactiveTitle | |
| 148 | 159 | styles.TabTitleActive = styles.TabActiveTitle | |
| 149 | - | styles.PickerName = styles.PickerBase | |
| 150 | - | styles.PickerSelectionName = styles.PickerSelection | |
| 151 | - | styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection) | |
| 160 | + | // These aliases retain their pre-semantic-role contracts for render paths | |
| 161 | + | // that have not migrated yet. | |
| 162 | + | styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t) | |
| 163 | + | styles.PickerSelectionName = EmphasisStyle(styles.Selection, t) | |
| 164 | + | styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t) | |
| 152 | 165 | return styles | |
| 153 | 166 | } | |
| @@ -28,7 +28,7 @@ func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme { | |||||
| 28 | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | 28 | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | ||
| 29 | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | 29 | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | ||
| 30 | styles := neutralStyles(t) | 30 | styles := neutralStyles(t) | ||
| 31 | - | if accent.Known && accent.IndexedOnly { | 31 | + | if accent.IndexedOnly { |
| 32 | styles = indexedStyles(styles, accent.Slot) | 32 | styles = indexedStyles(styles, accent.Slot) | ||
| 33 | } | 33 | } | ||
| 34 | return styles | 34 | return styles | ||
| @@ -54,12 +54,14 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 54 | PickerBase: ramp.SurfaceBar, | 54 | PickerBase: ramp.SurfaceBar, | ||
| 55 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | 55 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | ||
| 56 | PickerDescription: barDescription, | 56 | PickerDescription: barDescription, | ||
| 57 | - | PickerSeparator: ramp.BorderMuted, | 57 | + | // Separators are secondary text on the picker base surface, not |
| 58 | - | PromptBase: ramp.SurfaceBar, | 58 | + | // non-text borders; they therefore require the normal 4.5:1 contrast. |
| 59 | - | CopyStatus: ramp.SurfaceBar, | 59 | + | PickerSeparator: barDescription, |
| 60 | - | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | 60 | + | PromptBase: ramp.SurfaceBar, |
| 61 | + | CopyStatus: ramp.SurfaceBar, | |||
| 62 | + | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |||
| 61 | } | 63 | } | ||
| 62 | - | return withLegacyAliases(styles) | 64 | + | return withLegacyAliases(styles, t) |
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | func secondarySurface(base renderer.Style) renderer.Style { | 67 | func secondarySurface(base renderer.Style) renderer.Style { | ||
| @@ -77,16 +79,25 @@ func secondarySurface(base renderer.Style) renderer.Style { | |||||
| 77 | func indexedStyles(styles Styles, slot uint8) Styles { | 79 | func indexedStyles(styles Styles, slot uint8) Styles { | ||
| 78 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | 80 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | ||
| 79 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | 81 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | ||
| 82 | + | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | |||
| 83 | + | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |||
| 84 | + | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | |||
| 80 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | 85 | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | ||
| 86 | + | ||||
| 87 | + | // Compatibility aliases historically received the scheme-aware indexed | |||
| 88 | + | // blue fallback too. Keep the strict explicit index on each foreground | |||
| 89 | + | // decoration without changing any neutral surface background. | |||
| 81 | styles.Border = styles.BorderMuted | 90 | styles.Border = styles.BorderMuted | ||
| 91 | + | styles.PaletteDesc = styles.PickerDescription | |||
| 92 | + | styles.TabTitle = styles.TabInactiveTitle | |||
| 93 | + | styles.TabTitleActive = styles.TabActiveTitle | |||
| 94 | + | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | |||
| 82 | return styles | 95 | return styles | ||
| 83 | } | 96 | } | ||
| 84 | 97 | ||||
| 85 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | 98 | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | ||
| 86 | style.HasForegroundRGB = false | 99 | style.HasForegroundRGB = false | ||
| 87 | style.Foreground = int(slot) | 100 | style.Foreground = int(slot) | ||
| 88 | - | style.HasBackgroundRGB = false | |||
| 89 | - | style.Background = -1 | |||
| 90 | return style | 101 | return style | ||
| 91 | } | 102 | } | ||
| 92 | 103 | ||||
| @@ -126,7 +137,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 126 | CopyStatus: accent, | 137 | CopyStatus: accent, | ||
| 127 | SearchSelection: accent, | 138 | SearchSelection: accent, | ||
| 128 | } | 139 | } | ||
| 129 | - | return withLegacyAliases(styles) | 140 | + | return withLegacyAliases(styles, t) |
| 130 | } | 141 | } | ||
| 131 | 142 | ||||
| 132 | func neutralBorderStyle(t Theme) renderer.Style { | 143 | func neutralBorderStyle(t Theme) renderer.Style { | ||
| @@ -136,7 +147,7 @@ func neutralBorderStyle(t Theme) renderer.Style { | |||||
| 136 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | 147 | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | ||
| 137 | } | 148 | } | ||
| 138 | 149 | ||||
| 139 | - | func withLegacyAliases(styles Styles) Styles { | 150 | + | func withLegacyAliases(styles Styles, t Theme) Styles { |
| 140 | styles.StatusBar = styles.SurfaceBar | 151 | styles.StatusBar = styles.SurfaceBar | ||
| 141 | styles.Accent = styles.SurfaceActive | 152 | styles.Accent = styles.SurfaceActive | ||
| 142 | styles.Border = styles.BorderMuted | 153 | styles.Border = styles.BorderMuted | ||
| @@ -146,8 +157,10 @@ func withLegacyAliases(styles Styles) Styles { | |||||
| 146 | styles.TabNameActive = styles.TabActive | 157 | styles.TabNameActive = styles.TabActive | ||
| 147 | styles.TabTitle = styles.TabInactiveTitle | 158 | styles.TabTitle = styles.TabInactiveTitle | ||
| 148 | styles.TabTitleActive = styles.TabActiveTitle | 159 | styles.TabTitleActive = styles.TabActiveTitle | ||
| 149 | - | styles.PickerName = styles.PickerBase | 160 | + | // These aliases retain their pre-semantic-role contracts for render paths |
| 150 | - | styles.PickerSelectionName = styles.PickerSelection | 161 | + | // that have not migrated yet. |
| 151 | - | styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection) | 162 | + | styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t) |
| 163 | + | styles.PickerSelectionName = EmphasisStyle(styles.Selection, t) | |||
| 164 | + | styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t) | |||
| 152 | return styles | 165 | return styles | ||
| 153 | } | 166 | } | ||