rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "math"
11 "net/http"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/chromedp/chromedp"
17
18 "amadan.net/rastrillo/fiesta"
19 "amadan.net/rastrillo/fiesta/internal/harness"
20)
21
22// day derives its accent-dependent tokens from --rst-accent.
23// Two drives hold the two halves of that promise. The first reads every
24// derived token as Chromium paints it, in both schemes, and holds it to
25// the hex the design was measured with (the values day.css declared
26// before the derivation) within two units per channel, and to the Go
27// evaluator the contrast gate relies on within one, so the gate's
28// numbers are the browser's. The second puts an app stylesheet on the
29// page that sets --rst-accent alone, the way the design system's guide
30// says to, and reads the primary button, a hovered row, a focused
31// field, the current menu item and the current nav item: the first four
32// follow the override, in both schemes, and the nav item stays the
33// surface thumb day made it (C15), which is not an accent colour and so
34// has nothing to follow.
35
36// documentedDayColours is what day.css declared for each derived token
37// before the derivation, the colours the contrast table and the mockups were
38// measured with. The derivations have to land on these, not the other
39// way round: a formula that drifted would move the measured ratios.
40var documentedDayColours = map[string]map[string]string{
41 "light": {
42 "--rst-accent-strong": "#1844ac", "--rst-accent-soft": "#ecf1ff",
43 "--rst-accent-fill": "#2257df", "--rst-accent-fill-hover": "#1d4dc8", "--rst-accent-fill-edge": "#153ca0", "--rst-accent-fill-edge-hover": "#12358f",
44 "--rst-tint-hover": "#f3f7ff", "--rst-tint-press": "#eaf1fe", "--rst-tint-tap": "#eaf1fe",
45 "--rst-tint-card-hover": "#f3f7ff", "--rst-tint-tile-hover": "#f3f7ff", "--rst-tint-tile-press": "#eaf1fe", "--rst-tint-group-hover": "#f3f7ff",
46 "--rst-row-tint-hover": "#f3f7ff", "--rst-row-tint-press": "#eaf1fe",
47 "--rst-focus-edge": "#1f56d6", "--rst-focus-halo": "rgba(31, 86, 214, 0.22)",
48 "--rst-sheen-plain": "rgba(34, 87, 223, 0.09)", "--rst-light-rim": "#b9c6e4",
49 },
50 "dark": {
51 "--rst-accent-strong": "#a3c0ff", "--rst-accent-soft": "#16234a",
52 "--rst-accent-fill": "#2f61e8", "--rst-accent-fill-hover": "#2956d0", "--rst-accent-fill-edge": "#1f43a6", "--rst-accent-fill-edge-hover": "#1a3b95",
53 "--rst-tint-hover": "#171c28", "--rst-tint-press": "#182031", "--rst-tint-tap": "#182031",
54 "--rst-tint-card-hover": "#171c28", "--rst-tint-tile-hover": "#171c28", "--rst-tint-tile-press": "#182031", "--rst-tint-group-hover": "#171c28",
55 "--rst-row-tint-hover": "#171c28", "--rst-row-tint-press": "#182031",
56 "--rst-focus-edge": "#7aa2ff", "--rst-focus-halo": "rgba(122, 162, 255, 0.22)",
57 "--rst-sheen-plain": "rgba(255, 255, 255, 0.04)", "--rst-light-rim": "#5d6a86",
58 },
59}
60
61// paintedToken reads one token as the page resolves it: a probe span
62// painted with var(<token>), its computed background colour, and that
63// colour painted into a 1x1 canvas and read back as eight-bit sRGB, so
64// a relative colour (oklch) is checked as the engine converts it rather
65// than as a serialisation this suite parses. For a translucent token
66// the canvas read is useless (an un-premultiply at 9% alpha loses the
67// channels), so the serialisation is returned too and the caller reads
68// that instead.
69func paintedToken(t *testing.T, ctx context.Context, token string) (serialised string, pixel [4]int) {
70 t.Helper()
71 var raw string
72 mustRun(t, ctx, chromedp.Evaluate(`(() => {
73 const e = document.createElement("span"); e.style.background = "var(`+token+`)"; document.body.append(e);
74 const css = getComputedStyle(e).backgroundColor; e.remove();
75 const c = document.createElement("canvas"); c.width = c.height = 1;
76 const g = c.getContext("2d", { willReadFrequently: true });
77 g.clearRect(0, 0, 1, 1); g.fillStyle = "#000000"; g.fillStyle = css; g.fillRect(0, 0, 1, 1);
78 const d = g.getImageData(0, 0, 1, 1).data;
79 return JSON.stringify({Css: css, Px: [d[0], d[1], d[2], d[3]]});
80 })()`, &raw))
81 var out struct {
82 Css string
83 Px [4]int
84 }
85 if err := json.Unmarshal([]byte(raw), &out); err != nil {
86 t.Fatalf("%s: %v (%s)", token, err, raw)
87 }
88 return canonColours(out.Css), out.Px
89}
90
91// nearColour reports whether two colours (hex or rgba()) agree on every
92// channel within tol units and on alpha within tol/255.
93func nearColour(t *testing.T, got, want string, tol float64) bool {
94 t.Helper()
95 g, w := channelsOf(t, got), channelsOf(t, want)
96 for i := range g {
97 if math.Abs(g[i]-w[i]) > tol {
98 return false
99 }
100 }
101 // alphaOf reads the last number of any rgb() as the alpha, which
102 // is right for rgba() and wrong for Chromium's opaque rgb(r, g, b).
103 alpha := func(v string) float64 {
104 if strings.HasPrefix(v, "rgb(") {
105 return 1
106 }
107 return alphaOf(t, v)
108 }
109 return math.Abs(alpha(got)-alpha(want))*255 <= tol
110}
111
112func TestDaysDerivedTokensReproduceTheirDocumentedColours(t *testing.T) {
113 for _, scheme := range []string{"light", "dark"} {
114 t.Run(scheme, func(t *testing.T) {
115 rig := sizingRig(t, false, depthDoc(t))
116 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
117 defer cancel()
118 open(t, ctx, rig, "/", false)
119 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
120 resolved := resolvedTokens(t, "day")[scheme]
121 for token, want := range documentedDayColours[scheme] {
122 serialised, px := paintedToken(t, ctx, token)
123 got := serialised
124 if alphaOf(t, want) >= 1 {
125 got = fmt.Sprintf("#%02x%02x%02x", px[0], px[1], px[2])
126 if px[3] != 255 {
127 t.Errorf("%s painted with alpha %d, want opaque", token, px[3])
128 }
129 }
130 if !nearColour(t, got, want, 2) {
131 t.Errorf("%s paints as %s, want %s within 2 per channel (the hex the design was measured with)", token, got, want)
132 }
133 if !nearColour(t, got, resolved[token], 1) {
134 t.Errorf("%s paints as %s but the Go evaluator resolves it to %s; the contrast gate is reading a colour the browser does not paint", token, got, resolved[token])
135 }
136 t.Logf("%-30s %-26s documented %-26s evaluator %s", token, got, want, resolved[token])
137 }
138 })
139 }
140}
141
142// overrideAccent is the one line the design system's guide shows an
143// app writing: the docs' own violet, not a blue, so a token that kept
144// day's cobalt is caught.
145const overrideAccent = `<style>:root { --rst-accent: light-dark(#7c3aed, #a78bfa); }</style>`
146
147var overrideAccentHex = map[string]string{"light": "#7c3aed", "dark": "#a78bfa"}
148
149func TestOneAccentOverrideRecoloursDay(t *testing.T) {
150 doc := depthDoc(t)
151 doc["/"] = strings.Replace(doc["/"], `<link rel="stylesheet" href="/theme.css">`, `<link rel="stylesheet" href="/theme.css">`+overrideAccent, 1)
152 for _, scheme := range []string{"light", "dark"} {
153 t.Run(scheme, func(t *testing.T) {
154 rig := sizingRig(t, false, doc, harness.WithHover())
155 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
156 defer cancel()
157 open(t, ctx, rig, "/", false)
158 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
159 still(t, ctx)
160 // The expectation is the theme's own formulas evaluated with
161 // the override in place (derived_test.go), not a second copy
162 // of them; and it must differ from the default, or the drive
163 // would pass on a page the override never reached.
164 want := resolveWith(t, "day", scheme, map[string]string{"--rst-accent": overrideAccentHex[scheme]})
165 def := resolvedTokens(t, "day")[scheme]
166 for _, tok := range []string{"--rst-accent-fill", "--rst-accent-fill-hover", "--rst-accent-fill-edge-hover", "--rst-tint-hover", "--rst-focus-edge", "--rst-focus-halo", "--rst-accent-soft"} {
167 if nearColour(t, want[tok], def[tok], 2) {
168 t.Fatalf("%s resolves to %s with the override and %s without; the override does not reach it, so this drive could not tell", tok, want[tok], def[tok])
169 }
170 }
171 pixel := func(css string) string { return paintCSS(t, ctx, css) }
172 check := func(what, got, tok string) {
173 t.Helper()
174 if !nearColour(t, got, want[tok], 2) {
175 t.Errorf("%s is %s, want %s (%s derived from the overridden accent)", what, got, want[tok], tok)
176 }
177 }
178 check("primary button at rest", pixel(computed(t, ctx, "#b-primary", "background-color")), "--rst-accent-fill")
179 forceState(t, ctx, "#b-primary", "hover")
180 check("primary button hovered", pixel(computed(t, ctx, "#b-primary", "background-color")), "--rst-accent-fill-hover")
181 check("primary button's hovered edge", pixel(computed(t, ctx, "#b-primary", "border-top-color")), "--rst-accent-fill-edge-hover")
182 forceState(t, ctx, "#b-primary")
183 forceState(t, ctx, "#row-a", "hover")
184 check("hovered row", computed(t, ctx, "#row-a", "background-color"), "--rst-tint-hover")
185 forceState(t, ctx, "#row-a")
186 forceState(t, ctx, "#field", "focus", "focus-visible")
187 check("focused field's edge", computed(t, ctx, "#field", "border-top-color"), "--rst-focus-edge")
188 if sh := computed(t, ctx, "#field", "box-shadow"); !strings.Contains(sh, cssColour(t, want["--rst-focus-halo"])) {
189 t.Errorf("focused field's shadow is %s, want the halo %s in it", sh, cssColour(t, want["--rst-focus-halo"]))
190 }
191 forceState(t, ctx, "#field")
192 check("current menu item", computed(t, ctx, "#menu-cur", "background-color"), "--rst-accent-soft")
193 // Not accent-dependent, by design: the current nav item is a
194 // surface thumb in day (C15), so the override leaves it.
195 if got := computed(t, ctx, "#nav-y", "background-color"); got != cssColour(t, def["--rst-nav-current-bg"]) {
196 t.Errorf("current nav item is %s, want the surface %s: day's nav thumb is not an accent colour", got, cssColour(t, def["--rst-nav-current-bg"]))
197 }
198 })
199 }
200}
201
202// paintCSS paints a computed colour through a 1x1 canvas and returns
203// the eight-bit hex, as paintedToken does: a fill is a relative colour,
204// and the engine's own conversion is the thing under test.
205func paintCSS(t *testing.T, ctx context.Context, css string) string {
206 t.Helper()
207 var raw string
208 mustRun(t, ctx, chromedp.Evaluate(`(() => { const c = document.createElement("canvas"); c.width = c.height = 1; const g = c.getContext("2d", { willReadFrequently: true }); g.fillStyle = "#000000"; g.fillStyle = `+jsStr(css)+`; g.fillRect(0, 0, 1, 1); const d = g.getImageData(0, 0, 1, 1).data; return "#" + [d[0], d[1], d[2]].map(n => n.toString(16).padStart(2, "0")).join(""); })()`, &raw))
209 return raw
210}
211
212// accentSweep is the set of single-colour overrides the label check
213// walks: twelve hues at 30 degree steps, at a chroma a brand would use
214// (oklch 0.6 0.15), three achromatic accents, and the three sRGB
215// primaries' edges (cyan, green, yellow), the accents whose fills clip
216// hardest and gain the most luminance (rastrillo's review computed cyan's dark
217// hover at 4.43:1 under the sheen). A single colour, not a light-dark()
218// pair, so the same accent serves both schemes, which is what an app
219// that writes one hex gets.
220func accentSweep() []string {
221 out := []string{}
222 for h := 0; h < 360; h += 30 {
223 rgb, _ := oklchRGB(0.6, 0.15, float64(h))
224 out = append(out, hexOf(rgb))
225 }
226 return append(out, "#808080", "#111111", "#000000", "#00ffff", "#00ff00", "#ffff00")
227}
228
229// brightestInside photographs one element in the given states and
230// returns the brightest pixel of its interior as hex: the face as it is
231// painted, fill, shade and sheen composed by the engine. The interior
232// is the box inset by 3px, which leaves out the 1px border, the 1px
233// inset highlight under it (offset sideways by the light), and the
234// antialiased corner of a 4px radius, none of which sits under the
235// label; the label itself is at least 7px in. The caller hides the
236// label first, or its white glyphs would be the brightest pixels.
237func brightestInside(t *testing.T, ctx context.Context, selector string, states ...string) string {
238 t.Helper()
239 img := decodePNG(t, snap(t, ctx, selector, states...))
240 b := img.Bounds()
241 const inset = 3
242 if b.Dx() <= 2*inset || b.Dy() <= 2*inset {
243 t.Fatalf("%s photographs as %v, too small to have an interior", selector, b)
244 }
245 best, bestLum := "", -1.0
246 for y := b.Min.Y + inset; y < b.Max.Y-inset; y++ {
247 for x := b.Min.X + inset; x < b.Max.X-inset; x++ {
248 r, g, bl, _ := img.At(x, y).RGBA()
249 px := [3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8)}
250 if lum := relLuminance(px[0], px[1], px[2]); lum > bestLum {
251 best, bestLum = hexOf(px), lum
252 }
253 }
254 }
255 return best
256}
257
258// TestAnyAccentKeepsTheFillLabelReadable holds three findings of
259// rastrillo's reviews: a green-to-cyan accent at day's fill lightness clips to
260// sRGB and gains luminance, and a grey accent used to take a fixed
261// chroma at a powerless hue and come out crimson. For every accent in
262// the sweep, in both schemes, the primary button's label clears 4.5:1
263// on the fill at rest and hovered, measured from the colours Chromium
264// paints, and on the composed face, the brightest pixel of the button
265// as photographed with the sheen pinned under its top edge: the fill
266// under the face shade's white at rest, and under the shade and the
267// full sheen hovered. The bare fill cleared for every hue while the
268// composed face did not (cyan in dark: 4.74:1 bare, 4.15:1 composed),
269// which is what the review found. An achromatic accent gives an achromatic
270// fill.
271func TestAnyAccentKeepsTheFillLabelReadable(t *testing.T) {
272 for _, scheme := range []string{"light", "dark"} {
273 t.Run(scheme, func(t *testing.T) {
274 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
275 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
276 defer cancel()
277 open(t, ctx, rig, "/", false)
278 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
279 still(t, ctx)
280 // The sheen's centre is where light.js puts the pointer; 4px
281 // under the top edge is a pointer resting there, and puts the
282 // sheen's full strength on the shade's lightest rows.
283 mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = document.createElement("style"); s.id = "override"; document.head.append(s); const b = document.getElementById("b-primary"); b.style.setProperty("--rst-mx", "50%"); b.style.setProperty("--rst-my", "4px"); return true; })()`, nil))
284 hideLabel := func(hide bool) {
285 v := `""`
286 if hide {
287 v = `"transparent"`
288 }
289 mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("b-primary").style.color = `+v+`, true`, nil))
290 }
291 worst, worstComposed := 99.0, 99.0
292 for _, accent := range accentSweep() {
293 mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("override").textContent = `+jsStr(":root { --rst-accent: "+accent+"; }")+`, true`, nil))
294 label := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "color"))
295 fill := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "background-color"))
296 forceState(t, ctx, "#b-primary", "hover")
297 hover := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "background-color"))
298 forceState(t, ctx, "#b-primary")
299 hideLabel(true)
300 faceRest := brightestInside(t, ctx, "#b-primary")
301 faceHover := brightestInside(t, ctx, "#b-primary", "hover")
302 hideLabel(false)
303 for _, c := range []struct {
304 state, bg, face string
305 }{{"rest", fill, faceRest}, {"hover", hover, faceHover}} {
306 ratio, err := ContrastRatio(label, c.bg)
307 if err != nil {
308 t.Fatal(err)
309 }
310 composed, err := ContrastRatio(label, c.face)
311 if err != nil {
312 t.Fatal(err)
313 }
314 worst, worstComposed = min(worst, ratio), min(worstComposed, composed)
315 if ratio < 4.5 {
316 t.Errorf("accent %s: label %s on the %s fill %s is %.2f:1, under 4.5:1", accent, label, c.state, c.bg, ratio)
317 }
318 if composed < 4.5 {
319 t.Errorf("accent %s: label %s on the composed %s face %s (fill %s under the shade and sheen) is %.2f:1, under 4.5:1", accent, label, c.state, c.face, c.bg, composed)
320 }
321 if lf, lb := relLuminance(channels8(t, c.face)), relLuminance(channels8(t, c.bg)); lf < lb {
322 t.Errorf("accent %s: the composed %s face %s is darker than its fill %s; the photograph missed the shade and sheen, so its ratio proves nothing", accent, c.state, c.face, c.bg)
323 }
324 t.Logf("accent %s %-5s %s on %s = %.2f, on the composed face %s = %.2f", accent, c.state, label, c.bg, ratio, c.face, composed)
325 }
326 if accent == "#808080" || accent == "#111111" || accent == "#000000" {
327 r, g, b, _ := parseHex(fill)
328 if int(r)-int(g) > 2 || int(g)-int(r) > 2 || int(g)-int(b) > 2 || int(b)-int(g) > 2 {
329 t.Errorf("accent %s: the fill is %s, want a grey; the chroma must come from the accent, not from a fixed number at a powerless hue", accent, fill)
330 }
331 }
332 }
333 t.Logf("tightest pair across the sweep: %.2f:1 on the bare fill, %.2f:1 on the composed face", worst, worstComposed)
334 })
335 }
336}
337
338// channels8 is a hex colour's three channels, for relLuminance.
339func channels8(t *testing.T, hex string) (r, g, b uint8) {
340 t.Helper()
341 r, g, b, err := parseHex(hex)
342 if err != nil {
343 t.Fatal(err)
344 }
345 return r, g, b
346}
347
348// TestWithoutRelativeColourDayKeepsItsCobalt is review I1: the derived
349// fills live in an @supports block for oklch(from …), which is above
350// the stated Firefox floor. Served with that block's test string made
351// false, the way an engine without relative colour sees the file, the
352// primary button is day's cobalt with its white label, not a fill that
353// resolved to nothing. The control is the same page with the real
354// string, which paints the derived fill, so the rewrite is known to
355// take effect.
356func TestWithoutRelativeColourDayKeepsItsCobalt(t *testing.T) {
357 const real, forced = "oklch(from red l c h)", "oklch(from red l c zz)"
358 for _, c := range []struct {
359 name, test string
360 fill map[string]string
361 }{
362 {"forced false", forced, map[string]string{"light": "rgb(34, 87, 223)", "dark": "rgb(47, 97, 232)"}},
363 {"control", real, map[string]string{"light": "rgb(32, 89, 221)", "dark": "rgb(49, 96, 234)"}},
364 } {
365 t.Run(c.name, func(t *testing.T) {
366 theme, _ := fiesta.ThemeCSS("day")
367 if !bytes.Contains(theme, []byte(real)) {
368 t.Fatalf("day.css no longer gates its derived fills on %q; this drive has nothing to force", real)
369 }
370 theme = bytes.ReplaceAll(theme, []byte(real), []byte(c.test))
371 pages := depthDoc(t)
372 rig := harness.New(t, func(string) http.Handler {
373 mux := sizingMux(t, pages)
374 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
375 if r.URL.Path == "/theme.css" {
376 w.Header().Set("Content-Type", "text/css")
377 w.Write(theme)
378 return
379 }
380 mux.ServeHTTP(w, r)
381 })
382 })
383 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
384 defer cancel()
385 open(t, ctx, rig, "/", false)
386 for scheme, want := range c.fill {
387 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
388 fill, label := computed(t, ctx, "#b-primary", "background-color"), computed(t, ctx, "#b-primary", "color")
389 if fill != want || label != "rgb(255, 255, 255)" {
390 t.Errorf("%s: primary button is %s on %s, want rgb(255, 255, 255) on %s", scheme, label, fill, want)
391 }
392 }
393 })
394 }
395}
396