internal/web/tokens_test.go

32a5f76e5b270097b63a5bba9a43557cf50ad63d
gitbay/internal/web/tokens_test.go history · blame · raw

151 lines · 5424 bytes

  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}