detached 442f1fc

feat(theme): add WCAG contrast helpers

commit
442f1fcc52fb1e2abf71aa3d296c3ecbf5d0b967
author
brice · · 2026-07-17 08:11 UTC
parent
456f79f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +69 -1
M internal/usecase/theme/styles.go +31 -1
@@ -1,6 +1,36 @@
11 package theme
22
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+}
434
535 // PulseColor derives the attention pulse color at intensity while preserving
636 // the base style's remaining attributes.
@@ -1,6 +1,36 @@
1 package theme1 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 preserving35 // 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 (
77 "github.com/stretchr/testify/require"
88 )
99
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+
1048 func TestPulseColor(t *testing.T) {
1149 rgbBase := renderer.Style{
1250 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,

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close