internal/web/tokens_test.go

5daf169d25da8ed4b6ac17d1c7becff026f45513
gitbay/internal/web/tokens_test.go history · blame · raw

133 lines · 4450 bytes

  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}