feat(theme): add WCAG contrast helpers
- commit
- 442f1fcc52fb1e2abf71aa3d296c3ecbf5d0b967
- author
- brice · · 2026-07-17 08:11 UTC
- parent
- 456f79f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +69 -1
M internal/usecase/theme/styles.go +31 -1
| @@ -1,6 +1,36 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const accentContrastMin = 3.0 | |
| 10 | + | ||
| 11 | + | // RelativeLuminance returns a color's WCAG 2.x relative luminance. | |
| 12 | + | func RelativeLuminance(color renderer.RGB) float64 { | |
| 13 | + | linearize := func(channel uint8) float64 { | |
| 14 | + | srgb := float64(channel) / 255 | |
| 15 | + | if srgb <= 0.04045 { | |
| 16 | + | return srgb / 12.92 | |
| 17 | + | } | |
| 18 | + | return math.Pow((srgb+0.055)/1.055, 2.4) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | return 0.2126*linearize(color.R) + | |
| 22 | + | 0.7152*linearize(color.G) + | |
| 23 | + | 0.0722*linearize(color.B) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // ContrastRatio returns the WCAG 2.x contrast ratio between two colors. | |
| 27 | + | func ContrastRatio(a, b renderer.RGB) float64 { | |
| 28 | + | aLuminance := RelativeLuminance(a) | |
| 29 | + | bLuminance := RelativeLuminance(b) | |
| 30 | + | lighter := math.Max(aLuminance, bLuminance) | |
| 31 | + | darker := math.Min(aLuminance, bLuminance) | |
| 32 | + | return (lighter + 0.05) / (darker + 0.05) | |
| 33 | + | } | |
| 4 | 34 | ||
| 5 | 35 | // PulseColor derives the attention pulse color at intensity while preserving | |
| 6 | 36 | // the base style's remaining attributes. | |
| @@ -1,6 +1,36 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const accentContrastMin = 3.0 | |||
| 10 | + | ||||
| 11 | + | // RelativeLuminance returns a color's WCAG 2.x relative luminance. | |||
| 12 | + | func RelativeLuminance(color renderer.RGB) float64 { | |||
| 13 | + | linearize := func(channel uint8) float64 { | |||
| 14 | + | srgb := float64(channel) / 255 | |||
| 15 | + | if srgb <= 0.04045 { | |||
| 16 | + | return srgb / 12.92 | |||
| 17 | + | } | |||
| 18 | + | return math.Pow((srgb+0.055)/1.055, 2.4) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | return 0.2126*linearize(color.R) + | |||
| 22 | + | 0.7152*linearize(color.G) + | |||
| 23 | + | 0.0722*linearize(color.B) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // ContrastRatio returns the WCAG 2.x contrast ratio between two colors. | |||
| 27 | + | func ContrastRatio(a, b renderer.RGB) float64 { | |||
| 28 | + | aLuminance := RelativeLuminance(a) | |||
| 29 | + | bLuminance := RelativeLuminance(b) | |||
| 30 | + | lighter := math.Max(aLuminance, bLuminance) | |||
| 31 | + | darker := math.Min(aLuminance, bLuminance) | |||
| 32 | + | return (lighter + 0.05) / (darker + 0.05) | |||
| 33 | + | } | |||
| 4 | 34 | ||||
| 5 | // PulseColor derives the attention pulse color at intensity while preserving | 35 | // PulseColor derives the attention pulse color at intensity while preserving | ||
| 6 | // the base style's remaining attributes. | 36 | // the base style's remaining attributes. | ||
M internal/usecase/theme/styles_test.go +38 -0
| @@ -7,6 +7,44 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestRelativeLuminance(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | color renderer.RGB | |
| 14 | + | want float64 | |
| 15 | + | }{ | |
| 16 | + | {name: "black", color: renderer.RGB{}, want: 0}, | |
| 17 | + | {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1}, | |
| 18 | + | {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)}, | |
| 19 | + | {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923}, | |
| 20 | + | {name: "red", color: renderer.RGB{R: 255}, want: 0.2126}, | |
| 21 | + | {name: "green", color: renderer.RGB{G: 255}, want: 0.7152}, | |
| 22 | + | {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | got := RelativeLuminance(tt.color) | |
| 28 | + | require.InDelta(t, tt.want, got, 1e-12) | |
| 29 | + | require.Equal(t, got, RelativeLuminance(tt.color)) | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestContrastRatio(t *testing.T) { | |
| 35 | + | black := renderer.RGB{} | |
| 36 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |
| 37 | + | middle := renderer.RGB{R: 123, G: 45, B: 67} | |
| 38 | + | ||
| 39 | + | require.InDelta(t, 21, ContrastRatio(black, white), 1e-12) | |
| 40 | + | require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12) | |
| 41 | + | require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black)) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestAccentContrastMinimum(t *testing.T) { | |
| 45 | + | require.Equal(t, 3.0, accentContrastMin) | |
| 46 | + | } | |
| 47 | + | ||
| 10 | 48 | func TestPulseColor(t *testing.T) { | |
| 11 | 49 | rgbBase := renderer.Style{ | |
| 12 | 50 | Italic: true, | |
| @@ -7,6 +7,44 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestRelativeLuminance(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | color renderer.RGB | |||
| 14 | + | want float64 | |||
| 15 | + | }{ | |||
| 16 | + | {name: "black", color: renderer.RGB{}, want: 0}, | |||
| 17 | + | {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1}, | |||
| 18 | + | {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)}, | |||
| 19 | + | {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923}, | |||
| 20 | + | {name: "red", color: renderer.RGB{R: 255}, want: 0.2126}, | |||
| 21 | + | {name: "green", color: renderer.RGB{G: 255}, want: 0.7152}, | |||
| 22 | + | {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | got := RelativeLuminance(tt.color) | |||
| 28 | + | require.InDelta(t, tt.want, got, 1e-12) | |||
| 29 | + | require.Equal(t, got, RelativeLuminance(tt.color)) | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestContrastRatio(t *testing.T) { | |||
| 35 | + | black := renderer.RGB{} | |||
| 36 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |||
| 37 | + | middle := renderer.RGB{R: 123, G: 45, B: 67} | |||
| 38 | + | ||||
| 39 | + | require.InDelta(t, 21, ContrastRatio(black, white), 1e-12) | |||
| 40 | + | require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12) | |||
| 41 | + | require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black)) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestAccentContrastMinimum(t *testing.T) { | |||
| 45 | + | require.Equal(t, 3.0, accentContrastMin) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 10 | func TestPulseColor(t *testing.T) { | 48 | func TestPulseColor(t *testing.T) { | ||
| 11 | rgbBase := renderer.Style{ | 49 | rgbBase := renderer.Style{ | ||
| 12 | Italic: true, | 50 | Italic: true, | ||