feat(theme): add amber Warn border role distinct from Info
BoxWarn and BoxInfo toasts previously shared BorderMuted and were visually identical. Add a BorderWarn semantic role: it reuses the accent-derived weight scale activeBorder searches (so it keeps the accent's OKLab lightness/chroma band) but replaces the hue with a fixed amber target via a new OKLCh hue-shift primitive, so Warn always reads as amber regardless of the user's single accent hue. Wire noticeStylesFrom's BoxWarn to the new role; BoxInfo/BoxError unchanged.
- commit
- e6ae2e5da44b1691c8932a41068209a9ea2167bf
- author
- brice · · 2026-07-20 17:30 UTC
- parent
- 7a384bc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +216 -3
M internal/usecase/daemon/render_pipeline.go +3 -3
| @@ -307,13 +307,13 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | 309 | // noticeStylesFrom picks the toast box color per severity from the theme's | |
| 310 | - | // existing chrome roles. A first pass: dedicated notice styling is out of | |
| 311 | - | // scope here. | |
| 310 | + | // chrome roles. Warn uses the dedicated BorderWarn role so it reads as | |
| 311 | + | // distinct from Info instead of sharing BorderMuted with it. | |
| 312 | 312 | func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles { | |
| 313 | 313 | return ui.NoticeStyles{ | |
| 314 | 314 | Text: styles.PickerBase, | |
| 315 | 315 | BoxError: styles.BorderActive, | |
| 316 | - | BoxWarn: styles.BorderMuted, | |
| 316 | + | BoxWarn: styles.BorderWarn, | |
| 317 | 317 | BoxInfo: styles.BorderMuted, | |
| 318 | 318 | } | |
| 319 | 319 | } | |
| @@ -307,13 +307,13 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 307 | } | 307 | } | ||
| 308 | 308 | ||||
| 309 | // noticeStylesFrom picks the toast box color per severity from the theme's | 309 | // noticeStylesFrom picks the toast box color per severity from the theme's | ||
| 310 | - | // existing chrome roles. A first pass: dedicated notice styling is out of | 310 | + | // chrome roles. Warn uses the dedicated BorderWarn role so it reads as |
| 311 | - | // scope here. | 311 | + | // distinct from Info instead of sharing BorderMuted with it. |
| 312 | func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles { | 312 | func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles { | ||
| 313 | return ui.NoticeStyles{ | 313 | return ui.NoticeStyles{ | ||
| 314 | Text: styles.PickerBase, | 314 | Text: styles.PickerBase, | ||
| 315 | BoxError: styles.BorderActive, | 315 | BoxError: styles.BorderActive, | ||
| 316 | - | BoxWarn: styles.BorderMuted, | 316 | + | BoxWarn: styles.BorderWarn, |
| 317 | BoxInfo: styles.BorderMuted, | 317 | BoxInfo: styles.BorderMuted, | ||
| 318 | } | 318 | } | ||
| 319 | } | 319 | } | ||
M internal/usecase/daemon/render_pipeline_test.go +17 -0
| @@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 319 | 319 | }) | |
| 320 | 320 | } | |
| 321 | 321 | } | |
| 322 | + | ||
| 323 | + | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |
| 324 | + | muted := renderer.Style{Foreground: 1} | |
| 325 | + | active := renderer.Style{Foreground: 2} | |
| 326 | + | warn := renderer.Style{Foreground: 3} | |
| 327 | + | styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn} | |
| 328 | + | ||
| 329 | + | got := noticeStylesFrom(styles) | |
| 330 | + | ||
| 331 | + | require.Equal(t, active, got.BoxError) | |
| 332 | + | require.Equal(t, muted, got.BoxInfo) | |
| 333 | + | // BoxWarn must use the dedicated BorderWarn role, not fall back to the | |
| 334 | + | // same muted role BoxInfo uses - that was the pre-existing bug this | |
| 335 | + | // role fixes (Warn and Info toasts were visually identical). | |
| 336 | + | require.Equal(t, warn, got.BoxWarn) | |
| 337 | + | require.NotEqual(t, got.BoxInfo, got.BoxWarn) | |
| 338 | + | } | |
| @@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 319 | }) | 319 | }) | ||
| 320 | } | 320 | } | ||
| 321 | } | 321 | } | ||
| 322 | + | ||||
| 323 | + | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |||
| 324 | + | muted := renderer.Style{Foreground: 1} | |||
| 325 | + | active := renderer.Style{Foreground: 2} | |||
| 326 | + | warn := renderer.Style{Foreground: 3} | |||
| 327 | + | styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn} | |||
| 328 | + | ||||
| 329 | + | got := noticeStylesFrom(styles) | |||
| 330 | + | ||||
| 331 | + | require.Equal(t, active, got.BoxError) | |||
| 332 | + | require.Equal(t, muted, got.BoxInfo) | |||
| 333 | + | // BoxWarn must use the dedicated BorderWarn role, not fall back to the | |||
| 334 | + | // same muted role BoxInfo uses - that was the pre-existing bug this | |||
| 335 | + | // role fixes (Warn and Info toasts were visually identical). | |||
| 336 | + | require.Equal(t, warn, got.BoxWarn) | |||
| 337 | + | require.NotEqual(t, got.BoxInfo, got.BoxWarn) | |||
| 338 | + | } | |||
M internal/usecase/theme/color_oklab.go +34 -0
| @@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 { | |||
| 52 | 52 | return math.Hypot(color.A, color.B) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | + | // oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees | |
| 56 | + | // in [0, 360). | |
| 57 | + | type oklch struct { | |
| 58 | + | L float64 | |
| 59 | + | C float64 | |
| 60 | + | H float64 | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // oklabToOKLCh converts to the polar form used to isolate hue from | |
| 64 | + | // lightness and chroma. | |
| 65 | + | func oklabToOKLCh(color oklab) oklch { | |
| 66 | + | hue := math.Atan2(color.B, color.A) * 180 / math.Pi | |
| 67 | + | if hue < 0 { | |
| 68 | + | hue += 360 | |
| 69 | + | } | |
| 70 | + | return oklch{L: color.L, C: okLabChroma(color), H: hue} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // oklchToOKLab converts back from the polar form. | |
| 74 | + | func oklchToOKLab(color oklch) oklab { | |
| 75 | + | rad := color.H * math.Pi / 180 | |
| 76 | + | return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // shiftHue replaces color's OKLCh hue with targetDegrees while preserving | |
| 80 | + | // its lightness and chroma, so the result stays in the same value/intensity | |
| 81 | + | // band as the source. A near-neutral source (chroma close to zero) yields a | |
| 82 | + | // near-neutral result regardless of the target: hue is not meaningful there. | |
| 83 | + | func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB { | |
| 84 | + | lch := oklabToOKLCh(rgbToOKLab(color)) | |
| 85 | + | lch.H = targetDegrees | |
| 86 | + | return okLabToRGB(oklchToOKLab(lch)) | |
| 87 | + | } | |
| 88 | + | ||
| 55 | 89 | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | |
| 56 | 90 | // round-trip quantization changing terminal-provided colors. | |
| 57 | 91 | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | |
| @@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 { | |||||
| 52 | return math.Hypot(color.A, color.B) | 52 | return math.Hypot(color.A, color.B) | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | + | // oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees | |||
| 56 | + | // in [0, 360). | |||
| 57 | + | type oklch struct { | |||
| 58 | + | L float64 | |||
| 59 | + | C float64 | |||
| 60 | + | H float64 | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // oklabToOKLCh converts to the polar form used to isolate hue from | |||
| 64 | + | // lightness and chroma. | |||
| 65 | + | func oklabToOKLCh(color oklab) oklch { | |||
| 66 | + | hue := math.Atan2(color.B, color.A) * 180 / math.Pi | |||
| 67 | + | if hue < 0 { | |||
| 68 | + | hue += 360 | |||
| 69 | + | } | |||
| 70 | + | return oklch{L: color.L, C: okLabChroma(color), H: hue} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // oklchToOKLab converts back from the polar form. | |||
| 74 | + | func oklchToOKLab(color oklch) oklab { | |||
| 75 | + | rad := color.H * math.Pi / 180 | |||
| 76 | + | return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // shiftHue replaces color's OKLCh hue with targetDegrees while preserving | |||
| 80 | + | // its lightness and chroma, so the result stays in the same value/intensity | |||
| 81 | + | // band as the source. A near-neutral source (chroma close to zero) yields a | |||
| 82 | + | // near-neutral result regardless of the target: hue is not meaningful there. | |||
| 83 | + | func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB { | |||
| 84 | + | lch := oklabToOKLCh(rgbToOKLab(color)) | |||
| 85 | + | lch.H = targetDegrees | |||
| 86 | + | return okLabToRGB(oklchToOKLab(lch)) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 55 | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | 89 | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | ||
| 56 | // round-trip quantization changing terminal-provided colors. | 90 | // round-trip quantization changing terminal-provided colors. | ||
| 57 | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | 91 | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | ||
M internal/usecase/theme/color_oklab_test.go +75 -0
| @@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | func TestOKLabOKLChRoundTrip(t *testing.T) { | |
| 97 | + | colors := []oklab{ | |
| 98 | + | rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}), | |
| 99 | + | rgbToOKLab(renderer.RGB{R: 0xff}), | |
| 100 | + | rgbToOKLab(renderer.RGB{B: 0xff}), | |
| 101 | + | rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}), | |
| 102 | + | } | |
| 103 | + | for _, want := range colors { | |
| 104 | + | got := oklchToOKLab(oklabToOKLCh(want)) | |
| 105 | + | if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 { | |
| 106 | + | t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) { | |
| 112 | + | tests := []struct { | |
| 113 | + | name string | |
| 114 | + | rgb renderer.RGB | |
| 115 | + | want float64 | |
| 116 | + | }{ | |
| 117 | + | // Known reference hues, computed with the same conversion this | |
| 118 | + | // package uses: red sits near 29 degrees, blue near 264, amber | |
| 119 | + | // (#FFBF00) near 84. | |
| 120 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2}, | |
| 121 | + | {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1}, | |
| 122 | + | } | |
| 123 | + | for _, tt := range tests { | |
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |
| 125 | + | got := oklabToOKLCh(rgbToOKLab(tt.rgb)) | |
| 126 | + | if got.H < 0 || got.H >= 360 { | |
| 127 | + | t.Fatalf("hue %v out of [0, 360) range", got.H) | |
| 128 | + | } | |
| 129 | + | if math.Abs(got.H-tt.want) > 0.5 { | |
| 130 | + | t.Fatalf("hue = %v, want ~%v", got.H, tt.want) | |
| 131 | + | } | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) { | |
| 137 | + | tests := []struct { | |
| 138 | + | name string | |
| 139 | + | source renderer.RGB | |
| 140 | + | }{ | |
| 141 | + | {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |
| 142 | + | {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}}, | |
| 143 | + | {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}}, | |
| 144 | + | } | |
| 145 | + | const target = warnHueDegrees | |
| 146 | + | ||
| 147 | + | for _, tt := range tests { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source)) | |
| 150 | + | shifted := shiftHue(tt.source, target) | |
| 151 | + | shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted)) | |
| 152 | + | ||
| 153 | + | // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for | |
| 154 | + | // out-of-gamut combinations; allow a small tolerance. These three | |
| 155 | + | // sources sit safely inside the gamut at the amber target hue | |
| 156 | + | // (verified independently), so a tight tolerance still catches a | |
| 157 | + | // broken implementation. | |
| 158 | + | if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 { | |
| 159 | + | t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L) | |
| 160 | + | } | |
| 161 | + | if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 { | |
| 162 | + | t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C) | |
| 163 | + | } | |
| 164 | + | if math.Abs(shiftedLCh.H-target) > 1 { | |
| 165 | + | t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target) | |
| 166 | + | } | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 96 | 171 | func channelDelta(a, b uint8) uint8 { | |
| 97 | 172 | if a > b { | |
| 98 | 173 | return a - b | |
| @@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |||||
| 93 | } | 93 | } | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func TestOKLabOKLChRoundTrip(t *testing.T) { | |||
| 97 | + | colors := []oklab{ | |||
| 98 | + | rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}), | |||
| 99 | + | rgbToOKLab(renderer.RGB{R: 0xff}), | |||
| 100 | + | rgbToOKLab(renderer.RGB{B: 0xff}), | |||
| 101 | + | rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}), | |||
| 102 | + | } | |||
| 103 | + | for _, want := range colors { | |||
| 104 | + | got := oklchToOKLab(oklabToOKLCh(want)) | |||
| 105 | + | if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 { | |||
| 106 | + | t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) { | |||
| 112 | + | tests := []struct { | |||
| 113 | + | name string | |||
| 114 | + | rgb renderer.RGB | |||
| 115 | + | want float64 | |||
| 116 | + | }{ | |||
| 117 | + | // Known reference hues, computed with the same conversion this | |||
| 118 | + | // package uses: red sits near 29 degrees, blue near 264, amber | |||
| 119 | + | // (#FFBF00) near 84. | |||
| 120 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2}, | |||
| 121 | + | {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1}, | |||
| 122 | + | } | |||
| 123 | + | for _, tt := range tests { | |||
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 125 | + | got := oklabToOKLCh(rgbToOKLab(tt.rgb)) | |||
| 126 | + | if got.H < 0 || got.H >= 360 { | |||
| 127 | + | t.Fatalf("hue %v out of [0, 360) range", got.H) | |||
| 128 | + | } | |||
| 129 | + | if math.Abs(got.H-tt.want) > 0.5 { | |||
| 130 | + | t.Fatalf("hue = %v, want ~%v", got.H, tt.want) | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) { | |||
| 137 | + | tests := []struct { | |||
| 138 | + | name string | |||
| 139 | + | source renderer.RGB | |||
| 140 | + | }{ | |||
| 141 | + | {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |||
| 142 | + | {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}}, | |||
| 143 | + | {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}}, | |||
| 144 | + | } | |||
| 145 | + | const target = warnHueDegrees | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source)) | |||
| 150 | + | shifted := shiftHue(tt.source, target) | |||
| 151 | + | shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted)) | |||
| 152 | + | ||||
| 153 | + | // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for | |||
| 154 | + | // out-of-gamut combinations; allow a small tolerance. These three | |||
| 155 | + | // sources sit safely inside the gamut at the amber target hue | |||
| 156 | + | // (verified independently), so a tight tolerance still catches a | |||
| 157 | + | // broken implementation. | |||
| 158 | + | if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 { | |||
| 159 | + | t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L) | |||
| 160 | + | } | |||
| 161 | + | if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 { | |||
| 162 | + | t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C) | |||
| 163 | + | } | |||
| 164 | + | if math.Abs(shiftedLCh.H-target) > 1 { | |||
| 165 | + | t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target) | |||
| 166 | + | } | |||
| 167 | + | }) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 96 | func channelDelta(a, b uint8) uint8 { | 171 | func channelDelta(a, b uint8) uint8 { | ||
| 97 | if a > b { | 172 | if a > b { | ||
| 98 | return a - b | 173 | return a - b | ||
M internal/usecase/theme/ramp.go +28 -0
| @@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer" | |||
| 5 | 5 | const ( | |
| 6 | 6 | normalTextContrast = 4.5 | |
| 7 | 7 | borderContrast = 3.0 | |
| 8 | + | ||
| 9 | + | // warnHueDegrees is the fixed OKLCh hue target for the Warn border role. | |
| 10 | + | // Reference amber hues under this package's own rgbToOKLab: conventional | |
| 11 | + | // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at | |
| 12 | + | // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in | |
| 13 | + | // that band, so a warn border reads as amber/gold regardless of which | |
| 14 | + | // hue the user's single accent happens to occupy (blue ~256, red ~29, | |
| 15 | + | // green ~145 under the same conversion). | |
| 16 | + | warnHueDegrees = 75.0 | |
| 8 | 17 | ) | |
| 9 | 18 | ||
| 10 | 19 | // Ramp is the contrast-safe set of semantic terminal chrome colors. | |
| @@ -15,6 +24,7 @@ type Ramp struct { | |||
| 15 | 24 | SurfaceActive renderer.Style | |
| 16 | 25 | BorderMuted renderer.Style | |
| 17 | 26 | BorderActive renderer.Style | |
| 27 | + | BorderWarn renderer.Style | |
| 18 | 28 | ||
| 19 | 29 | background renderer.RGB | |
| 20 | 30 | accent renderer.RGB | |
| @@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 52 | 62 | SurfaceActive: active, | |
| 53 | 63 | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 54 | 64 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 65 | + | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 55 | 66 | background: t.Background, | |
| 56 | 67 | accent: accent.RGB, | |
| 57 | 68 | rgb: true, | |
| @@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 130 | 141 | return neutralBorder(t) | |
| 131 | 142 | } | |
| 132 | 143 | ||
| 144 | + | // warnBorder searches the same accent-derived weight scale activeBorder | |
| 145 | + | // uses, but replaces each candidate's hue with the fixed amber target before | |
| 146 | + | // checking contrast: the result keeps the accent's lightness/chroma band | |
| 147 | + | // (so it belongs to the ramp) while always reading as amber, regardless of | |
| 148 | + | // the user's accent hue. | |
| 149 | + | func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |
| 150 | + | for weight := 100; weight >= 0; weight-- { | |
| 151 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 152 | + | amber := shiftHue(candidate, warnHueDegrees) | |
| 153 | + | if ContrastRatio(amber, adjacent) >= borderContrast { | |
| 154 | + | return foregroundStyle(amber) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return neutralBorder(t) | |
| 158 | + | } | |
| 159 | + | ||
| 133 | 160 | func foregroundStyle(color renderer.RGB) renderer.Style { | |
| 134 | 161 | style := renderer.DefaultStyle() | |
| 135 | 162 | style.HasForegroundRGB = true | |
| @@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp { | |||
| 146 | 173 | SurfaceActive: styles.SurfaceActive, | |
| 147 | 174 | BorderMuted: styles.BorderMuted, | |
| 148 | 175 | BorderActive: styles.BorderActive, | |
| 176 | + | BorderWarn: styles.BorderWarn, | |
| 149 | 177 | } | |
| 150 | 178 | } | |
| 151 | 179 | ||
| @@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer" | |||||
| 5 | const ( | 5 | const ( | ||
| 6 | normalTextContrast = 4.5 | 6 | normalTextContrast = 4.5 | ||
| 7 | borderContrast = 3.0 | 7 | borderContrast = 3.0 | ||
| 8 | + | ||||
| 9 | + | // warnHueDegrees is the fixed OKLCh hue target for the Warn border role. | |||
| 10 | + | // Reference amber hues under this package's own rgbToOKLab: conventional | |||
| 11 | + | // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at | |||
| 12 | + | // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in | |||
| 13 | + | // that band, so a warn border reads as amber/gold regardless of which | |||
| 14 | + | // hue the user's single accent happens to occupy (blue ~256, red ~29, | |||
| 15 | + | // green ~145 under the same conversion). | |||
| 16 | + | warnHueDegrees = 75.0 | |||
| 8 | ) | 17 | ) | ||
| 9 | 18 | ||||
| 10 | // Ramp is the contrast-safe set of semantic terminal chrome colors. | 19 | // Ramp is the contrast-safe set of semantic terminal chrome colors. | ||
| @@ -15,6 +24,7 @@ type Ramp struct { | |||||
| 15 | SurfaceActive renderer.Style | 24 | SurfaceActive renderer.Style | ||
| 16 | BorderMuted renderer.Style | 25 | BorderMuted renderer.Style | ||
| 17 | BorderActive renderer.Style | 26 | BorderActive renderer.Style | ||
| 27 | + | BorderWarn renderer.Style | |||
| 18 | 28 | ||||
| 19 | background renderer.RGB | 29 | background renderer.RGB | ||
| 20 | accent renderer.RGB | 30 | accent renderer.RGB | ||
| @@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||||
| 52 | SurfaceActive: active, | 62 | SurfaceActive: active, | ||
| 53 | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | 63 | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 54 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | 64 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 65 | + | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | |||
| 55 | background: t.Background, | 66 | background: t.Background, | ||
| 56 | accent: accent.RGB, | 67 | accent: accent.RGB, | ||
| 57 | rgb: true, | 68 | rgb: true, | ||
| @@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||||
| 130 | return neutralBorder(t) | 141 | return neutralBorder(t) | ||
| 131 | } | 142 | } | ||
| 132 | 143 | ||||
| 144 | + | // warnBorder searches the same accent-derived weight scale activeBorder | |||
| 145 | + | // uses, but replaces each candidate's hue with the fixed amber target before | |||
| 146 | + | // checking contrast: the result keeps the accent's lightness/chroma band | |||
| 147 | + | // (so it belongs to the ramp) while always reading as amber, regardless of | |||
| 148 | + | // the user's accent hue. | |||
| 149 | + | func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 150 | + | for weight := 100; weight >= 0; weight-- { | |||
| 151 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |||
| 152 | + | amber := shiftHue(candidate, warnHueDegrees) | |||
| 153 | + | if ContrastRatio(amber, adjacent) >= borderContrast { | |||
| 154 | + | return foregroundStyle(amber) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return neutralBorder(t) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 133 | func foregroundStyle(color renderer.RGB) renderer.Style { | 160 | func foregroundStyle(color renderer.RGB) renderer.Style { | ||
| 134 | style := renderer.DefaultStyle() | 161 | style := renderer.DefaultStyle() | ||
| 135 | style.HasForegroundRGB = true | 162 | style.HasForegroundRGB = true | ||
| @@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp { | |||||
| 146 | SurfaceActive: styles.SurfaceActive, | 173 | SurfaceActive: styles.SurfaceActive, | ||
| 147 | BorderMuted: styles.BorderMuted, | 174 | BorderMuted: styles.BorderMuted, | ||
| 148 | BorderActive: styles.BorderActive, | 175 | BorderActive: styles.BorderActive, | ||
| 176 | + | BorderWarn: styles.BorderWarn, | |||
| 149 | } | 177 | } | ||
| 150 | } | 178 | } | ||
| 151 | 179 | ||||
M internal/usecase/theme/ramp_test.go +50 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||
| 74 | 75 | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 78 | + | func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) { | |
| 79 | + | theme := rampTheme(false) | |
| 80 | + | ||
| 81 | + | for name, accentRGB := range map[string]renderer.RGB{ | |
| 82 | + | "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 83 | + | "red accent": {R: 0xcc, G: 0x66, B: 0x66}, | |
| 84 | + | } { | |
| 85 | + | t.Run(name, func(t *testing.T) { | |
| 86 | + | accent := Accent{RGB: accentRGB, Known: true} | |
| 87 | + | ramp := BuildRamp(theme, accent) | |
| 88 | + | ||
| 89 | + | require.True(t, ramp.BorderWarn.HasForegroundRGB) | |
| 90 | + | require.False(t, ramp.BorderWarn.HasBackgroundRGB) | |
| 91 | + | ||
| 92 | + | accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H | |
| 93 | + | warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H | |
| 94 | + | distanceToTarget := func(h float64) float64 { | |
| 95 | + | d := math.Abs(h - warnHueDegrees) | |
| 96 | + | if d > 180 { | |
| 97 | + | d = 360 - d | |
| 98 | + | } | |
| 99 | + | return d | |
| 100 | + | } | |
| 101 | + | // Warn must land measurably closer to the amber target than the | |
| 102 | + | // accent itself sits, in both directions (blue and the | |
| 103 | + | // adversarial case of a red accent, which sits close to zero | |
| 104 | + | // degrees and could otherwise get confused with an | |
| 105 | + | // under-rotated "warm" accent border). | |
| 106 | + | require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue)) | |
| 107 | + | require.Less(t, distanceToTarget(warnHue), 5.0) | |
| 108 | + | ||
| 109 | + | // Distinct from the sibling borders by more than a rounding | |
| 110 | + | // difference: use the same OKLab distance scale accent | |
| 111 | + | // clustering already treats as "different colors" | |
| 112 | + | // (accentClusterDistance groups colors within 0.04). | |
| 113 | + | warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB) | |
| 114 | + | mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB) | |
| 115 | + | activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB) | |
| 116 | + | require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance) | |
| 117 | + | require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance) | |
| 118 | + | ||
| 119 | + | require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast) | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 77 | 124 | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | |
| 78 | 125 | theme := rampTheme(false) | |
| 79 | 126 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | |
| @@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||
| 94 | 141 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | |
| 95 | 142 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | |
| 96 | 143 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | |
| 144 | + | require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn) | |
| 97 | 145 | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | |
| 98 | 146 | require.True(t, resolved.Styles.TabActive.Bold) | |
| 99 | 147 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | |
| @@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |||
| 112 | 160 | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | |
| 113 | 161 | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | |
| 114 | 162 | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | |
| 163 | + | require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground) | |
| 164 | + | require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB) | |
| 115 | 165 | ||
| 116 | 166 | theme.TrueColor = true | |
| 117 | 167 | theme.UsePalette = false | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||||
| 74 | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | 75 | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 78 | + | func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) { | |||
| 79 | + | theme := rampTheme(false) | |||
| 80 | + | ||||
| 81 | + | for name, accentRGB := range map[string]renderer.RGB{ | |||
| 82 | + | "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9}, | |||
| 83 | + | "red accent": {R: 0xcc, G: 0x66, B: 0x66}, | |||
| 84 | + | } { | |||
| 85 | + | t.Run(name, func(t *testing.T) { | |||
| 86 | + | accent := Accent{RGB: accentRGB, Known: true} | |||
| 87 | + | ramp := BuildRamp(theme, accent) | |||
| 88 | + | ||||
| 89 | + | require.True(t, ramp.BorderWarn.HasForegroundRGB) | |||
| 90 | + | require.False(t, ramp.BorderWarn.HasBackgroundRGB) | |||
| 91 | + | ||||
| 92 | + | accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H | |||
| 93 | + | warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H | |||
| 94 | + | distanceToTarget := func(h float64) float64 { | |||
| 95 | + | d := math.Abs(h - warnHueDegrees) | |||
| 96 | + | if d > 180 { | |||
| 97 | + | d = 360 - d | |||
| 98 | + | } | |||
| 99 | + | return d | |||
| 100 | + | } | |||
| 101 | + | // Warn must land measurably closer to the amber target than the | |||
| 102 | + | // accent itself sits, in both directions (blue and the | |||
| 103 | + | // adversarial case of a red accent, which sits close to zero | |||
| 104 | + | // degrees and could otherwise get confused with an | |||
| 105 | + | // under-rotated "warm" accent border). | |||
| 106 | + | require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue)) | |||
| 107 | + | require.Less(t, distanceToTarget(warnHue), 5.0) | |||
| 108 | + | ||||
| 109 | + | // Distinct from the sibling borders by more than a rounding | |||
| 110 | + | // difference: use the same OKLab distance scale accent | |||
| 111 | + | // clustering already treats as "different colors" | |||
| 112 | + | // (accentClusterDistance groups colors within 0.04). | |||
| 113 | + | warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB) | |||
| 114 | + | mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB) | |||
| 115 | + | activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB) | |||
| 116 | + | require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance) | |||
| 117 | + | require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance) | |||
| 118 | + | ||||
| 119 | + | require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast) | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 77 | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | 124 | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | ||
| 78 | theme := rampTheme(false) | 125 | theme := rampTheme(false) | ||
| 79 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | 126 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | ||
| @@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||||
| 94 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | 141 | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | ||
| 95 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | 142 | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | ||
| 96 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | 143 | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | ||
| 144 | + | require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn) | |||
| 97 | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | 145 | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | ||
| 98 | require.True(t, resolved.Styles.TabActive.Bold) | 146 | require.True(t, resolved.Styles.TabActive.Bold) | ||
| 99 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | 147 | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | ||
| @@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |||||
| 112 | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | 160 | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | ||
| 113 | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | 161 | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | ||
| 114 | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | 162 | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | ||
| 163 | + | require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground) | |||
| 164 | + | require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB) | |||
| 115 | 165 | ||||
| 116 | theme.TrueColor = true | 166 | theme.TrueColor = true | ||
| 117 | theme.UsePalette = false | 167 | theme.UsePalette = false | ||
M internal/usecase/theme/resolved_styles.go +3 -0
| @@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 45 | 45 | SurfaceActive: ramp.SurfaceActive, | |
| 46 | 46 | BorderMuted: ramp.BorderMuted, | |
| 47 | 47 | BorderActive: ramp.BorderActive, | |
| 48 | + | BorderWarn: ramp.BorderWarn, | |
| 48 | 49 | NeutralBorder: neutralBorder(t), | |
| 49 | 50 | ||
| 50 | 51 | TabInactive: ramp.SurfaceInactive, | |
| @@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style { | |||
| 81 | 82 | func indexedStyles(styles Styles, slot uint8) Styles { | |
| 82 | 83 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | |
| 83 | 84 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |
| 85 | + | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | |
| 84 | 86 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | |
| 85 | 87 | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |
| 86 | 88 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | |
| @@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles { | |||
| 131 | 133 | SurfaceActive: accent, | |
| 132 | 134 | BorderMuted: border, | |
| 133 | 135 | BorderActive: border, | |
| 136 | + | BorderWarn: border, | |
| 134 | 137 | NeutralBorder: border, | |
| 135 | 138 | ||
| 136 | 139 | TabInactive: EmphasisStyle(status, t), | |
| @@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 45 | SurfaceActive: ramp.SurfaceActive, | 45 | SurfaceActive: ramp.SurfaceActive, | ||
| 46 | BorderMuted: ramp.BorderMuted, | 46 | BorderMuted: ramp.BorderMuted, | ||
| 47 | BorderActive: ramp.BorderActive, | 47 | BorderActive: ramp.BorderActive, | ||
| 48 | + | BorderWarn: ramp.BorderWarn, | |||
| 48 | NeutralBorder: neutralBorder(t), | 49 | NeutralBorder: neutralBorder(t), | ||
| 49 | 50 | ||||
| 50 | TabInactive: ramp.SurfaceInactive, | 51 | TabInactive: ramp.SurfaceInactive, | ||
| @@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style { | |||||
| 81 | func indexedStyles(styles Styles, slot uint8) Styles { | 82 | func indexedStyles(styles Styles, slot uint8) Styles { | ||
| 82 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | 83 | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | ||
| 83 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | 84 | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | ||
| 85 | + | styles.BorderWarn = indexedForeground(styles.BorderWarn, slot) | |||
| 84 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | 86 | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | ||
| 85 | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | 87 | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | ||
| 86 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | 88 | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | ||
| @@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 131 | SurfaceActive: accent, | 133 | SurfaceActive: accent, | ||
| 132 | BorderMuted: border, | 134 | BorderMuted: border, | ||
| 133 | BorderActive: border, | 135 | BorderActive: border, | ||
| 136 | + | BorderWarn: border, | |||
| 134 | NeutralBorder: border, | 137 | NeutralBorder: border, | ||
| 135 | 138 | ||||
| 136 | TabInactive: EmphasisStyle(status, t), | 139 | TabInactive: EmphasisStyle(status, t), | ||
M internal/usecase/theme/styles.go +6 -0
| @@ -72,6 +72,12 @@ type Styles struct { | |||
| 72 | 72 | SurfaceActive renderer.Style | |
| 73 | 73 | BorderMuted renderer.Style | |
| 74 | 74 | BorderActive renderer.Style | |
| 75 | + | // BorderWarn is the amber-hued warn-severity border. Unlike every other | |
| 76 | + | // role in this camaïeu palette it does not blend toward the resolved | |
| 77 | + | // accent color: it keeps the accent's lightness/chroma band but shifts | |
| 78 | + | // hue to a fixed amber target (see warnHueDegrees), so Warn reads as | |
| 79 | + | // amber and stays distinct from Error regardless of the accent hue. | |
| 80 | + | BorderWarn renderer.Style | |
| 75 | 81 | // NeutralBorder is the non-accent structural border for pane dividers and | |
| 76 | 82 | // unfocused pane title bars. It is resolved with the rest of the immutable | |
| 77 | 83 | // chrome snapshot so composition never derives theme colors. | |
| @@ -72,6 +72,12 @@ type Styles struct { | |||||
| 72 | SurfaceActive renderer.Style | 72 | SurfaceActive renderer.Style | ||
| 73 | BorderMuted renderer.Style | 73 | BorderMuted renderer.Style | ||
| 74 | BorderActive renderer.Style | 74 | BorderActive renderer.Style | ||
| 75 | + | // BorderWarn is the amber-hued warn-severity border. Unlike every other | |||
| 76 | + | // role in this camaïeu palette it does not blend toward the resolved | |||
| 77 | + | // accent color: it keeps the accent's lightness/chroma band but shifts | |||
| 78 | + | // hue to a fixed amber target (see warnHueDegrees), so Warn reads as | |||
| 79 | + | // amber and stays distinct from Error regardless of the accent hue. | |||
| 80 | + | BorderWarn renderer.Style | |||
| 75 | // NeutralBorder is the non-accent structural border for pane dividers and | 81 | // NeutralBorder is the non-accent structural border for pane dividers and | ||
| 76 | // unfocused pane title bars. It is resolved with the rest of the immutable | 82 | // unfocused pane title bars. It is resolved with the rest of the immutable | ||
| 77 | // chrome snapshot so composition never derives theme colors. | 83 | // chrome snapshot so composition never derives theme colors. | ||