| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "math" |
| 6 | "regexp" |
| 7 | "slices" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "amadan.net/rastrillo/fiesta" |
| 13 | ) |
| 14 | |
| 15 | // The contrast gate in contrast_test.go measures token pairs. A button's |
| 16 | // label does not sit on a token: it sits on the fill, under a face |
| 17 | // gradient that lightens the top by --rst-depth-face and darkens the |
| 18 | // bottom by the same, under a sheen while the pointer is on it, and |
| 19 | // over a press that darkens by a wash plus a spot while it is held. |
| 20 | // This file composes those points and holds each to |
| 21 | // 4.5:1. The review computed 4.48:1 for a 15% sheen on the light |
| 22 | // primary and the cap came down; if a value fails, lower the shade |
| 23 | // or the sheen, never lighten the fill. |
| 24 | |
| 25 | func faceShadeShape(t *testing.T) { |
| 26 | t.Helper() |
| 27 | const shape = "linear-gradient(rgb(255 255 255 / var(--rst-depth-face, 0)), rgb(0 0 0 / var(--rst-depth-face, 0)))" |
| 28 | decls, _ := parseDeclarations(t, "tokens.css", string(fiesta.TokensCSS())) |
| 29 | if !slices.ContainsFunc(decls, func(d declaration) bool { return d.Property == "--rst-btn-face" && fullValue(d) == shape }) { |
| 30 | t.Fatal("tokens.css no longer shades a filled face with white at the top and black at the bottom by --rst-depth-face; this test measures that shape") |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func alphaOf(t *testing.T, v string) float64 { |
| 35 | t.Helper() |
| 36 | v = strings.TrimSpace(v) |
| 37 | if v == "transparent" { |
| 38 | return 0 |
| 39 | } |
| 40 | if strings.HasPrefix(v, "#") { |
| 41 | return 1 |
| 42 | } |
| 43 | m := regexp.MustCompile(`rgba?\([^)]*?[,/]\s*([0-9.]+)\s*\)$`).FindStringSubmatch(v) |
| 44 | if m == nil { |
| 45 | t.Fatalf("cannot read an alpha from %q", v) |
| 46 | } |
| 47 | a, err := strconv.ParseFloat(m[1], 64) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | return a |
| 52 | } |
| 53 | |
| 54 | func channelsOf(t *testing.T, v string) [3]float64 { |
| 55 | t.Helper() |
| 56 | v = strings.TrimSpace(v) |
| 57 | if v == "transparent" { |
| 58 | return [3]float64{} |
| 59 | } |
| 60 | if strings.HasPrefix(v, "#") { |
| 61 | r, g, b, err := parseHex(v) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | return [3]float64{float64(r), float64(g), float64(b)} |
| 66 | } |
| 67 | m := regexp.MustCompile(`rgba?\(\s*([0-9]+)[,\s]+([0-9]+)[,\s]+([0-9]+)`).FindStringSubmatch(v) |
| 68 | if m == nil { |
| 69 | t.Fatalf("cannot read channels from %q", v) |
| 70 | } |
| 71 | var out [3]float64 |
| 72 | for i := range out { |
| 73 | n, _ := strconv.Atoi(m[i+1]) |
| 74 | out[i] = float64(n) |
| 75 | } |
| 76 | return out |
| 77 | } |
| 78 | |
| 79 | // over composites fg at alpha a over an opaque bg in sRGB, which is what |
| 80 | // a gradient layer does, and returns hex for ContrastRatio. |
| 81 | func over(t *testing.T, bg, fg string, a float64) string { |
| 82 | t.Helper() |
| 83 | B, F := channelsOf(t, bg), channelsOf(t, fg) |
| 84 | var out [3]uint8 |
| 85 | for i := range out { |
| 86 | out[i] = uint8(math.Round(F[i]*a + B[i]*(1-a))) |
| 87 | } |
| 88 | return hexOf(out) |
| 89 | } |
| 90 | |
| 91 | func hexChannels(t *testing.T, v string) string { |
| 92 | t.Helper() |
| 93 | c := channelsOf(t, v) |
| 94 | return fmt.Sprintf("#%02x%02x%02x", int(c[0]), int(c[1]), int(c[2])) |
| 95 | } |
| 96 | |
| 97 | // pressNumber reads a number token, from the dark scheme blocks for |
| 98 | // dark, and fails on anything that is not a number: a value that parsed |
| 99 | // as 0 would make every press look safe. (Not "number": the package |
| 100 | // imports golang.org/x/text/number.) |
| 101 | func pressNumber(t *testing.T, theme, scheme, name string) float64 { |
| 102 | t.Helper() |
| 103 | v := themeTokens(t, theme)["light"][name] |
| 104 | if scheme == "dark" { |
| 105 | css, _ := fiesta.ThemeCSS(theme) |
| 106 | if dark, ok := schemeNumberBlocks(t, theme, string(css))[name]; ok { |
| 107 | v = dark |
| 108 | } |
| 109 | } |
| 110 | f, err := strconv.ParseFloat(v, 64) |
| 111 | if err != nil { |
| 112 | t.Fatalf("%s/%s %s = %q is not a number", theme, scheme, name, v) |
| 113 | } |
| 114 | return f |
| 115 | } |
| 116 | |
| 117 | // TestButtonFacesClearContrastAtTheirLightestAndDarkestPoints holds |
| 118 | // every composed face to 4.5:1. |
| 119 | func TestButtonFacesClearContrastAtTheirLightestAndDarkestPoints(t *testing.T) { |
| 120 | faceShadeShape(t) |
| 121 | for _, theme := range fiesta.ThemeNames() { |
| 122 | for _, scheme := range []string{"light", "dark"} { |
| 123 | tok := resolvedTokens(t, theme)[scheme] |
| 124 | shade := pressNumber(t, theme, scheme, "--rst-depth-face") |
| 125 | check := func(label, text, face string) { |
| 126 | t.Helper() |
| 127 | r, err := ContrastRatio(text, face) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if r < 4.5 { |
| 132 | t.Errorf("%s/%s %s: %s on the composed face %s = %.2f:1, want >= 4.5:1", theme, scheme, label, text, face, r) |
| 133 | } |
| 134 | t.Logf("%s/%s %s: %.2f:1", theme, scheme, label, r) |
| 135 | } |
| 136 | for _, v := range []struct{ name, fill, hover, text, wash, spot string }{ |
| 137 | {"primary", tok["--rst-accent-fill"], tok["--rst-accent-fill-hover"], tok["--rst-on-accent-fill"], "--rst-press-wash", "--rst-press-spot"}, |
| 138 | {"danger", tok["--rst-danger-fill"], tok["--rst-danger-fill-hover"], tok["--rst-on-danger-fill"], "--rst-press-wash", "--rst-press-spot"}, |
| 139 | } { |
| 140 | lightest := over(t, over(t, v.fill, "#ffffff", shade), "#ffffff", alphaOf(t, tok["--rst-sheen-fill"])) |
| 141 | check(v.name+" lightest (top of the shade, full sheen)", v.text, lightest) |
| 142 | darkest := over(t, over(t, over(t, v.hover, "#000000", shade), "#000000", pressNumber(t, theme, scheme, v.wash)), "#000000", pressNumber(t, theme, scheme, v.spot)) |
| 143 | check(v.name+" darkest (bottom of the shade, whole press)", v.text, darkest) |
| 144 | } |
| 145 | sheen := tok["--rst-sheen-plain"] |
| 146 | check("plain lightest (hovered, full sheen)", tok["--rst-control-text-hover"], over(t, tok["--rst-control-face-hover"], hexChannels(t, sheen), alphaOf(t, sheen))) |
| 147 | check("plain darkest (hovered, whole press)", tok["--rst-control-text-hover"], over(t, over(t, tok["--rst-control-face-hover"], "#000000", pressNumber(t, theme, scheme, "--rst-press-wash-plain")), "#000000", pressNumber(t, theme, scheme, "--rst-press-spot-plain"))) |
| 148 | if tok["--rst-control-face-press"] != "transparent" { |
| 149 | check("ghost pressed", tok["--rst-text"], tok["--rst-control-face-press"]) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |