Commit f493006847

f493006847e3a386c0170c6b8edb95809abc1a6d

parent: 82ae29eaa8

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 06:18 UTC

web: token contrast test

Ref #218
internal/web/tokens_test.go added +133
@@ -0,0 +1,133 @@
1package web
2
3import (
4 "math"
5 "regexp"
6 "strconv"
7 "strings"
8 "testing"
9)
10
11// parseTokens returns the --name: value pairs of the light :root block
12// and of the :root block inside the dark media query. A value that is
13// itself a var() is resolved one level deep within the same scheme.
14func parseTokens(css []byte) (light, dark map[string]string) {
15 s := string(css)
16 rootRe := regexp.MustCompile(`(?s):root\s*\{(.*?)\}`)
17 darkIdx := strings.Index(s, "@media (prefers-color-scheme: dark)")
18 if darkIdx < 0 {
19 return nil, nil
20 }
21 parse := func(block string) map[string]string {
22 m := map[string]string{}
23 lineRe := regexp.MustCompile(`--([a-z0-9-]+)\s*:\s*([^;]+);`)
24 for _, mm := range lineRe.FindAllStringSubmatch(block, -1) {
25 v := strings.TrimSpace(mm[2])
26 if i := strings.Index(v, "/*"); i >= 0 {
27 v = strings.TrimSpace(v[:i])
28 }
29 m[mm[1]] = v
30 }
31 for k, v := range m {
32 if strings.HasPrefix(v, "var(--") {
33 ref := strings.TrimSuffix(strings.TrimPrefix(v, "var(--"), ")")
34 if rv, ok := m[ref]; ok {
35 m[k] = rv
36 }
37 }
38 }
39 return m
40 }
41 lm := rootRe.FindStringSubmatch(s[:darkIdx])
42 dm := rootRe.FindStringSubmatch(s[darkIdx:])
43 if lm == nil || dm == nil {
44 return nil, nil
45 }
46 light = parse(lm[1])
47 dark = parse(dm[1])
48 for k, v := range light {
49 if _, ok := dark[k]; !ok && strings.HasPrefix(v, "#") {
50 dark[k] = v // a light-only colour is a bug; keep it visible below
51 }
52 }
53 return light, dark
54}
55
56func hexChannel(h string) float64 {
57 n, _ := strconv.ParseUint(h, 16, 8)
58 c := float64(n) / 255
59 if c <= 0.03928 {
60 return c / 12.92
61 }
62 return math.Pow((c+0.055)/1.055, 2.4)
63}
64
65func luminanceHex(hex string) float64 {
66 hex = strings.TrimPrefix(hex, "#")
67 if len(hex) == 3 {
68 hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
69 }
70 return 0.2126*hexChannel(hex[0:2]) + 0.7152*hexChannel(hex[2:4]) + 0.0722*hexChannel(hex[4:6])
71}
72
73func contrastHex(a, b string) float64 {
74 la, lb := luminanceHex(a), luminanceHex(b)
75 if la < lb {
76 la, lb = lb, la
77 }
78 return (la + 0.05) / (lb + 0.05)
79}
80
81// TestTokenContrast is the contract for the colour tokens in style.css:
82// every text colour clears WCAG AA on every ground it lands on, in both
83// schemes. The hex values in the stylesheet are free to move as long as
84// this passes.
85func TestTokenContrast(t *testing.T) {
86 light, dark := parseTokens(StyleCSS)
87 if light == nil || dark == nil {
88 t.Fatal("could not find the light and dark :root blocks in style.css")
89 }
90 type check struct {
91 fg, bg string
92 floor float64
93 }
94 checks := []check{
95 {"fg", "canvas", 7}, {"fg", "surface", 7},
96 {"muted", "canvas", 4.5}, {"muted", "surface", 4.5},
97 {"link", "canvas", 4.5}, {"link", "surface", 4.5}, {"link", "hover", 4.5},
98 {"fill-fg", "fill", 4.5}, {"fill", "surface", 3},
99 {"line", "canvas", 3},
100 {"mark", "canvas", 3},
101 {"warn", "canvas", 4.5}, {"warn", "surface", 4.5},
102 {"ok", "canvas", 4.5}, {"ok", "surface", 4.5},
103 {"bad", "canvas", 4.5}, {"bad", "surface", 4.5},
104 {"done", "canvas", 4.5}, {"done", "surface", 4.5},
105 {"focus", "canvas", 3},
106 {"shell-fg", "shell-bg", 7}, {"shell-muted", "shell-bg", 4.5}, {"shell-mark", "shell-bg", 3},
107 }
108 for name, scheme := range map[string]map[string]string{"light": light, "dark": dark} {
109 for _, tok := range []string{"canvas", "surface", "inset", "hover", "line", "faint", "fg", "muted", "link", "fill", "fill-fg", "mark", "warn", "ok", "bad", "done", "neutral", "focus", "shell-bg", "shell-fg", "shell-muted", "shell-mark"} {
110 v, ok := scheme[tok]
111 if !ok || !strings.HasPrefix(v, "#") {
112 t.Errorf("%s: --%s is missing or not a hex colour (%q)", name, tok, v)
113 }
114 }
115 if t.Failed() {
116 continue
117 }
118 for _, c := range checks {
119 if got := contrastHex(scheme[c.fg], scheme[c.bg]); got < c.floor {
120 t.Errorf("%s: --%s (%s) on --%s (%s) is %.2f:1, want >= %.1f", name, c.fg, scheme[c.fg], c.bg, scheme[c.bg], got, c.floor)
121 }
122 }
123 // The surface ladder must be visible: canvas, surface and inset
124 // are three grounds, not one.
125 lc, ls, _ := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"])
126 if r := (math.Max(lc, ls) + 0.05) / (math.Min(lc, ls) + 0.05); r < 1.15 {
127 t.Errorf("%s: canvas %s and surface %s are %.2f apart, want >= 1.15", name, scheme["canvas"], scheme["surface"], r)
128 }
129 if scheme["inset"] == scheme["surface"] {
130 t.Errorf("%s: inset and surface are the same colour %s", name, scheme["inset"])
131 }
132 }
133}