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
3// The browser drive for the tinted hairline (design doc §6-v2.2).
4//
5// The rake line — a 2.5rem accent stroke drawn over the page header's
6// rule by [rst-page-header]::after — is retired. The header rule moved
7// onto the theme axis as one DERIVED token, --rst-header-rule, and the
8// pseudo-element's content is now none. Three claims need proving, and
9// none of them can be proved by reading CSS:
10//
11// 1. the ::after draws nothing, in every theme and in both schemes;
12// 2. the rule resolves to what the theme's token says — which for
13// signal means an oklab mix a stylesheet cannot be asked to
14// evaluate for us;
15// 3. each theme's rule is the one it ruled: plain and day the line
16// itself (day's is neutral since the depth design, C15), signal
17// visibly tinted, all from one structural rule in tokens.css.
18//
19// Claim 2 is why this file computes oklab in Go. Reading the rule and
20// comparing it to a probe painted with var(--rst-header-rule) would
21// only prove the header names the token — it would agree with any
22// value the token happened to hold, including a wrong one. So the
23// expected colour is derived independently, from the theme's own
24// --rst-accent and --rst-line as the browser resolves them, mixed here
25// at the percentage Paul ruled, and compared with what Chromium
26// painted. Two implementations of the same arithmetic, one of them
27// ours.
28//
29// Colours come back through a 1×1 canvas rather than as text.
30// getComputedStyle serialises a color-mix() result differently across
31// engine versions (rgb(), color(srgb …), oklab()), and a drive that
32// parses a serialisation is a drive that breaks on a Chromium upgrade
33// while the pixels are fine. fillStyle accepts whatever the engine
34// parses and getImageData hands back the eight-bit sRGB triple that was
35// actually painted.
36//
37// ── The control ──────────────────────────────────────────────────────
38//
39// §7-v2: a gate nobody has watched fail is a gate nobody has tested.
40// Set RST_HEADER_RULE_FALSIFY to "<theme>=<css colour>" and the served
41// theme's --rst-header-rule declaration is rewritten before the browser
42// sees it, so a drive that finds every rule correct can be watched
43// finding a wrong one:
44//
45// RST_HEADER_RULE_FALSIFY='day=#ff0000' go test -tags browser ./check/ \
46// -run TestTheHeaderRuleIsTheThemesTintedHairline -v
47//
48// Unset — which is how CI runs it — it does nothing at all.
49//
50// The pixel strip has a control of its own, RST_RAKE_LINE_CONTROL=1,
51// which appends the deleted flourish back onto the served tokens.css.
52// It is the variable rastrillo's gallery sweep uses as well, and it
53// exists here for a narrower reason: the strip's claim is that the
54// decoration is uniform across the header, and the only way to know a
55// uniformity check works is to watch it meet something that is not
56// uniform.
57package check
58
59import (
60 "bytes"
61 "context"
62 "encoding/json"
63 "fmt"
64 "image/png"
65 "math"
66 "net/http"
67 "os"
68 "regexp"
69 "strings"
70 "testing"
71 "time"
72
73 cdppage "github.com/chromedp/cdproto/page"
74 "github.com/chromedp/chromedp"
75
76 "amadan.net/rastrillo/fiesta"
77 "amadan.net/rastrillo/fiesta/internal/harness"
78)
79
80// headerRuleMix is the design decision, transcribed: how much of the
81// accent each theme folds into its line. plain has no accent hue to
82// fold, so its token is the line itself and its share is zero.
83//
84// This table and the themes' declarations are meant to stay in
85// lockstep, the same way contrast_test.go's pair table and the
86// themes' published ratios do. It is written here rather than parsed
87// out of the CSS on purpose: a parser would read whatever the file
88// says and agree with it, which is not a check.
89var headerRuleMix = map[string]float64{
90 "day": 0.00, // day: neutral since the depth design (C15); its rule is the line itself.
91 "plain": 0.00,
92 "signal": 0.45,
93}
94
95// headerRuleFalsify reads the control's environment variable and
96// returns the theme to corrupt and what to corrupt it to.
97func headerRuleFalsify(t *testing.T) (theme, value string) {
98 t.Helper()
99 raw := os.Getenv("RST_HEADER_RULE_FALSIFY")
100 if raw == "" {
101 return "", ""
102 }
103 name, v, ok := strings.Cut(raw, "=")
104 if !ok || name == "" || v == "" {
105 t.Fatalf("RST_HEADER_RULE_FALSIFY=%q: want <theme>=<css colour>", raw)
106 }
107 if _, known := headerRuleMix[name]; !known {
108 t.Fatalf("RST_HEADER_RULE_FALSIFY names theme %q, which is not shipped", name)
109 }
110 t.Logf("CONTROL: serving themes/%s.css with --rst-header-rule rewritten to %q; this run is EXPECTED to fail on %s", name, v, name)
111 return name, v
112}
113
114var headerRuleDecl = regexp.MustCompile(`--rst-header-rule:[^;]*;`)
115
116// headerRulePage serves tokens.css, one theme, and one page header, at
117// /<theme>/<scheme>/<dir>/. Everything the page needs is on the page:
118// no script, no fonts, no network.
119func headerRulePage(t *testing.T) http.Handler {
120 t.Helper()
121 badTheme, badValue := headerRuleFalsify(t)
122
123 mux := http.NewServeMux()
124 mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) {
125 w.Header().Set("Content-Type", "text/css")
126 w.Write(fiesta.TokensCSS())
127 w.Write(rakeLineControl(t))
128 })
129 mux.HandleFunc("GET /theme/{name}", func(w http.ResponseWriter, r *http.Request) {
130 name := strings.TrimSuffix(r.PathValue("name"), ".css")
131 css, ok := fiesta.ThemeCSS(name)
132 if !ok {
133 http.Error(w, "no theme", http.StatusNotFound)
134 return
135 }
136 if name == badTheme {
137 css = headerRuleDecl.ReplaceAll(css, []byte("--rst-header-rule: "+badValue+";"))
138 }
139 w.Header().Set("Content-Type", "text/css")
140 w.Write(css)
141 })
142 mux.HandleFunc("GET /{theme}/{scheme}/{dir}/", func(w http.ResponseWriter, r *http.Request) {
143 theme, scheme, dir := r.PathValue("theme"), r.PathValue("scheme"), r.PathValue("dir")
144 lang := "en"
145 if dir == "rtl" {
146 lang = "ar"
147 }
148 w.Header().Set("Content-Type", "text/html; charset=utf-8")
149 fmt.Fprintf(w, `<!doctype html><html lang=%q dir=%q data-theme=%q><head><meta charset="utf-8">`+
150 `<title>header rule</title>`+
151 `<link rel="stylesheet" href="/tokens.css">`+
152 `<link rel="stylesheet" href="/theme/%s.css">`+
153 `</head><body><div rst-page>`+
154 `<header rst-page-header id="h">`+
155 `<div rst-page-header-titles><h1>Posts</h1>`+
156 `<p rst-page-header-sub>Everything you have written, newest first.</p></div>`+
157 `</header></div>`+
158 `<span id="accent" style="background: var(--rst-accent)"></span>`+
159 `<span id="line" style="background: var(--rst-line)"></span>`+
160 `</body></html>`, lang, dir, scheme, theme)
161 })
162 return mux
163}
164
165// headerRuleReading is one measurement of one header. The four
166// geometry fields are the clip for the pixel strip below, in CSS px.
167type headerRuleReading struct {
168 Rule, Accent, Line []int // eight-bit sRGB, as painted
169 AfterContent string // getComputedStyle(h, "::after").content
170 AfterWidth int // the ::after's painted width, in px
171 BorderWidth string
172 BorderStyle string
173 Left, Bottom float64
174 HeaderWidth float64
175 Dir string
176}
177
178// headerRuleMeasure paints each colour into a 1×1 canvas and reads the
179// pixel back, so the drive never has to parse a CSS serialisation. See
180// the file comment.
181const headerRuleMeasure = `(() => {
182 const canvas = document.createElement("canvas");
183 canvas.width = canvas.height = 1;
184 const g = canvas.getContext("2d", { willReadFrequently: true });
185 const px = css => {
186 g.clearRect(0, 0, 1, 1);
187 g.fillStyle = "#000000";
188 g.fillStyle = css;
189 g.fillRect(0, 0, 1, 1);
190 const d = g.getImageData(0, 0, 1, 1).data;
191 return [d[0], d[1], d[2]];
192 };
193 const h = document.getElementById("h");
194 const s = getComputedStyle(h);
195 const after = getComputedStyle(h, "::after");
196 const box = h.getBoundingClientRect();
197 return JSON.stringify({
198 Rule: px(s.borderBottomColor),
199 Accent: px(getComputedStyle(document.getElementById("accent")).backgroundColor),
200 Line: px(getComputedStyle(document.getElementById("line")).backgroundColor),
201 AfterContent: after.content,
202 // With content:none there is no box to lay out, so the pseudo-element
203 // has no width. A stroke that came back would have one.
204 AfterWidth: Math.round(parseFloat(after.width) || 0),
205 BorderWidth: s.borderBottomWidth,
206 BorderStyle: s.borderBottomStyle,
207 // The clip for the pixel strip. Geometry only — this side of the
208 // drive deliberately makes no claim about what the rule looks like
209 // across its width; that is the screenshot's job.
210 Left: box.left,
211 Bottom: box.bottom,
212 HeaderWidth: box.width,
213 Dir: getComputedStyle(document.documentElement).direction,
214 });
215})()`
216
217// srgbToLinearChannel and the two matrices below are Ottosson's oklab,
218// the space CSS names in "in oklab". Written out rather than pulled in
219// so this file owes nothing to a dependency for the one number it
220// exists to check.
221func srgbToLinearChannel(c float64) float64 {
222 if c <= 0.04045 {
223 return c / 12.92
224 }
225 return math.Pow((c+0.055)/1.055, 2.4)
226}
227
228func linearToSRGBChannel(c float64) float64 {
229 if c <= 0.0031308 {
230 return c * 12.92
231 }
232 return 1.055*math.Pow(c, 1/2.4) - 0.055
233}
234
235// toOklab converts an eight-bit sRGB triple to oklab.
236func toOklab(rgb []int) [3]float64 {
237 r := srgbToLinearChannel(float64(rgb[0]) / 255)
238 g := srgbToLinearChannel(float64(rgb[1]) / 255)
239 b := srgbToLinearChannel(float64(rgb[2]) / 255)
240 l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
241 m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
242 s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
243 return [3]float64{
244 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
245 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
246 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
247 }
248}
249
250// fromOklab converts back, rounding to eight bits the way a painted
251// pixel is. Values are clamped: every mix under test sits between two
252// in-gamut colours, so a clamp here is a guard, not gamut mapping.
253func fromOklab(lab [3]float64) []int {
254 l := lab[0] + 0.3963377774*lab[1] + 0.2158037573*lab[2]
255 m := lab[0] - 0.1055613458*lab[1] - 0.0638541728*lab[2]
256 s := lab[0] - 0.0894841775*lab[1] - 1.2914855480*lab[2]
257 l, m, s = l*l*l, m*m*m, s*s*s
258 lin := [3]float64{
259 +4.0767416621*l - 3.3077115913*m + 0.2309699292*s,
260 -1.2684380046*l + 2.6097574011*m - 0.3413193965*s,
261 -0.0041960863*l - 0.7034186147*m + 1.7076147010*s,
262 }
263 out := make([]int, 3)
264 for i, v := range lin {
265 c := math.Round(linearToSRGBChannel(v) * 255)
266 out[i] = int(math.Min(255, math.Max(0, c)))
267 }
268 return out
269}
270
271// mixOklab is CSS's color-mix(in oklab, a <share>, b): the two colours
272// converted to oklab, interpolated, converted back. Both are opaque, so
273// there is no premultiplication to do.
274func mixOklab(a, b []int, share float64) []int {
275 la, lb := toOklab(a), toOklab(b)
276 var out [3]float64
277 for i := range out {
278 out[i] = la[i]*share + lb[i]*(1-share)
279 }
280 return fromOklab(out)
281}
282
283func rgbClose(got, want []int, tol int) bool {
284 if len(got) != 3 || len(want) != 3 {
285 return false
286 }
287 for i := range got {
288 if d := got[i] - want[i]; d > tol || d < -tol {
289 return false
290 }
291 }
292 return true
293}
294
295// rgbDistance is a plain euclidean sRGB distance, used only to order the
296// three themes by how tinted their rule is. It is not a perceptual
297// metric and does not need to be: the question is "is signal's further
298// from grey than day's", and every channel here moves the same way.
299func rgbDistance(a, b []int) float64 {
300 var sum float64
301 for i := range a {
302 d := float64(a[i] - b[i])
303 sum += d * d
304 }
305 return math.Sqrt(sum)
306}
307
308func rgbString(c []int) string {
309 if len(c) != 3 {
310 return "?"
311 }
312 return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2])
313}
314
315// TestTheHeaderRuleIsTheThemesTintedHairline is the drive.
316//
317// Bug classes it exists to catch — each of which leaves a page that
318// renders and says nothing wrong:
319//
320// - the ::after survives somewhere, so the retired flourish is still
321// on screen (and still reads as a progress bar at 12%);
322// - the border still names --rst-line, so all three themes draw the
323// same grey and the theme axis gained a token nothing uses;
324// - a theme's percentage drifts from the decision — 45% typed as 4.5%,
325// or a tint creeping back into day's neutral rule — which is
326// invisible against a grey line and exactly the kind of thing a
327// source-reading test would confirm rather than catch;
328// - a theme declares the token but derives it from the wrong pair, so
329// changing the accent leaves the rule behind, which is the drift
330// the derivation exists to prevent;
331// - the rule is right in light and wrong in dark, or the other way
332// round, because light-dark() crept back into a derived value.
333func TestTheHeaderRuleIsTheThemesTintedHairline(t *testing.T) {
334 rig := harness.New(t, func(string) http.Handler { return headerRulePage(t) })
335
336 // Wall-clock against a real browser, and the same budget and
337 // reasoning as every other drive in this package: the deadline
338 // exists so a hang fails as itself, not to race a busy runner.
339 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
340 defer cancel()
341
342 // tint[theme][scheme] is how far that rule sits from that theme's own
343 // line colour. Filled as the sweep runs, checked for ordering after.
344 tint := map[string]map[string]float64{}
345
346 for _, theme := range fiesta.ThemeNames() {
347 share, ruled := headerRuleMix[theme]
348 if !ruled {
349 t.Errorf("theme %q is shipped but headerRuleMix does not say how much accent its rule carries; add it, with the number the design settled on", theme)
350 continue
351 }
352 tint[theme] = map[string]float64{}
353 for _, scheme := range []string{"light", "dark"} {
354 for _, dir := range []string{"ltr", "rtl"} {
355 name := theme + "/" + scheme + "/" + dir
356 var raw string
357 if err := chromedp.Run(ctx,
358 chromedp.EmulateViewport(1280, 900),
359 chromedp.Navigate(rig.Origin+"/"+theme+"/"+scheme+"/"+dir+"/"),
360 chromedp.WaitVisible(`#h`, chromedp.ByQuery),
361 chromedp.Evaluate(headerRuleMeasure, &raw),
362 ); err != nil {
363 t.Fatalf("%s: driving the header: %v", name, err)
364 }
365 var got headerRuleReading
366 if err := json.Unmarshal([]byte(raw), &got); err != nil {
367 t.Fatalf("%s: reading the measurement (%q): %v", name, raw, err)
368 }
369
370 // 1. The rake line is gone.
371 if got.AfterContent != "none" {
372 t.Errorf("%s: [rst-page-header]::after has content %q, want \"none\" — the rake line is retired (design doc §6-v2.2)", name, got.AfterContent)
373 }
374 if got.AfterWidth != 0 {
375 t.Errorf("%s: [rst-page-header]::after still lays out a %dpx box; a retired pseudo-element has none", name, got.AfterWidth)
376 }
377
378 // 2. The rule is the theme's derived token, measured.
379 want := mixOklab(got.Accent, got.Line, share)
380 if !rgbClose(got.Rule, want, 2) {
381 t.Errorf("%s: the header rule painted %s; --rst-accent %s mixed into --rst-line %s at %.0f%% in oklab is %s",
382 name, rgbString(got.Rule), rgbString(got.Accent), rgbString(got.Line), share*100, rgbString(want))
383 }
384 if got.BorderWidth != "1px" || got.BorderStyle != "solid" {
385 t.Errorf("%s: the header rule is %s %s, want 1px solid", name, got.BorderWidth, got.BorderStyle)
386 }
387
388 // 3. RTL. The rule spans the header in both directions —
389 // which is the whole difference from the stroke it
390 // replaced, and the reason this is checked in ar rather
391 // than assumed from "it is a border".
392 wantDir := dir
393 if got.Dir != wantDir {
394 t.Errorf("%s: the document resolved direction %q, want %q — the RTL half of this drive measured an LTR page", name, got.Dir, wantDir)
395 }
396 if got.HeaderWidth < 100 {
397 t.Errorf("%s: the header measured %.0fpx wide; nothing useful can be read off a strip that narrow", name, got.HeaderWidth)
398 } else {
399 headerRuleStrip(ctx, t, name, got, want)
400 }
401
402 if dir == "ltr" {
403 tint[theme][scheme] = rgbDistance(got.Rule, got.Line)
404 t.Logf("%s: rule %s line %s accent %s (tint distance %.1f)",
405 name, rgbString(got.Rule), rgbString(got.Line), rgbString(got.Accent), tint[theme][scheme])
406 }
407 }
408 }
409 }
410
411 // Three results from one structural rule. plain and day are the
412 // line exactly: plain has no hue to fold in and day chose a neutral
413 // rule (C15); signal folds in 45% and must read as tinted.
414 for _, scheme := range []string{"light", "dark"} {
415 plain, day, signal := tint["plain"][scheme], tint["day"][scheme], tint["signal"][scheme]
416 if plain != 0 {
417 t.Errorf("%s: plain's rule sits %.1f from its own line; plain has no accent hue to fold in, so it should be the line exactly", scheme, plain)
418 }
419 if day != 0 {
420 t.Errorf("%s: day's rule sits %.1f from its line; day's rule is neutral (C15)", scheme, day)
421 }
422 if signal <= 0 {
423 t.Errorf("%s: signal's rule is its line exactly; the 45%% tint did not survive to the pixel", scheme)
424 }
425 }
426}
427
428// headerRuleStrip is the measurement that replaced a tautology.
429//
430// The drive used to assert that the rule spanned the header by
431// comparing two Go fields filled from the same JavaScript expression.
432// That could never fail, and the RTL claim — that the decoration is
433// symmetric now, where the retired stroke was anchored to the inline
434// start — rested on it. This reads pixels instead.
435//
436// It captures a strip the full width of the header, from three CSS px
437// above its bottom edge to three below, and requires every row in it to
438// be one colour across. Nothing about a border-bottom guarantees that:
439// the retired stroke sat at inset-block-end: -1px, BELOW the border
440// box, which is why the strip deliberately extends past it rather than
441// clipping to the element. A 2.5rem stroke over a 1200px header leaves
442// one row that is accent for forty pixels and line colour for the rest,
443// and a row like that is what this fails on.
444//
445// It also requires the strip to actually contain the rule, so a run
446// that clipped the wrong six rows reports that rather than passing on a
447// uniformly empty picture. The tolerance there is loose (6/255) because
448// a fractional bottom edge blends the border row with the surface
449// behind it; the exact colour is the canvas reading's job, and that one
450// is held to 2.
451func headerRuleStrip(ctx context.Context, t *testing.T, name string, got headerRuleReading, wantRule []int) {
452 t.Helper()
453 const pad = 3
454 clip := &cdppage.Viewport{
455 X: math.Round(got.Left),
456 Y: math.Round(got.Bottom) - pad,
457 Width: math.Round(got.HeaderWidth),
458 Height: 2 * pad,
459 Scale: 1,
460 }
461 var buf []byte
462 if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
463 var err error
464 buf, err = cdppage.CaptureScreenshot().
465 WithFormat(cdppage.CaptureScreenshotFormatPng).
466 WithFromSurface(true).
467 WithCaptureBeyondViewport(true).
468 WithClip(clip).
469 Do(ctx)
470 return err
471 })); err != nil {
472 t.Errorf("%s: capturing the rule strip: %v", name, err)
473 return
474 }
475 img, err := png.Decode(bytes.NewReader(buf))
476 if err != nil {
477 t.Errorf("%s: decoding the rule strip: %v", name, err)
478 return
479 }
480 b := img.Bounds()
481 if b.Dx() < 100 || b.Dy() < 2*pad {
482 t.Errorf("%s: the rule strip came back %dx%d; expected roughly %.0fx%d", name, b.Dx(), b.Dy(), clip.Width, 2*pad)
483 return
484 }
485
486 at := func(x, y int) []int {
487 r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
488 return []int{int(r >> 8), int(g >> 8), int(bl >> 8)}
489 }
490 sawRule := false
491 for y := 0; y < b.Dy(); y++ {
492 first := at(0, y)
493 for x := 1; x < b.Dx(); x++ {
494 if px := at(x, y); !rgbClose(px, first, 2) {
495 t.Errorf("%s: the rule strip is not one colour across. Row %d of %d starts %s and is %s at x=%d of %d. The header's decoration is a border that spans it; a row that changes colour partway is a stroke laid over part of it, which is what §6-v2.2 retired",
496 name, y, b.Dy(), rgbString(first), rgbString(px), x, b.Dx())
497 return
498 }
499 }
500 if rgbClose(first, wantRule, 6) {
501 sawRule = true
502 }
503 }
504 if !sawRule {
505 t.Errorf("%s: no row of the %d-row strip around the header's bottom edge is the rule colour %s; the strip was clipped somewhere else and its uniformity proves nothing", name, b.Dy(), rgbString(wantRule))
506 }
507}
508
509// rakeLineControl returns the retired flourish, byte for byte as this
510// branch deleted it, when RST_RAKE_LINE_CONTROL is set — and nothing
511// otherwise. Appended to the served tokens.css it wins on cascade
512// order, so the control exercises the real deletion rather than a
513// stand-in for it.
514//
515// It is what the pixel strip is measured against. A 2.5rem stroke over
516// a 1200px header leaves one row that is accent for forty pixels and
517// line colour for the rest, which is exactly the shape a uniformity
518// check has to catch and exactly the shape a border can never make.
519func rakeLineControl(t *testing.T) []byte {
520 t.Helper()
521 if os.Getenv("RST_RAKE_LINE_CONTROL") == "" {
522 return nil
523 }
524 t.Logf("CONTROL: serving tokens.css with the rake line appended; this run is EXPECTED to fail")
525 return []byte(`
526[rst-page-header]::after {
527 background: var(--rst-accent);
528 block-size: 2px;
529 content: "";
530 inline-size: 2.5rem;
531 inset-block-end: -1px;
532 inset-inline-start: 0;
533 position: absolute;
534}
535`)
536}
537