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