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 "context"
7 "fmt"
8 "math"
9 "regexp"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/chromedp/cdproto/input"
16 "github.com/chromedp/chromedp"
17
18 "amadan.net/rastrillo/fiesta"
19 "amadan.net/rastrillo/fiesta/internal/harness"
20)
21
22// layers splits a computed background-image into its top-level layers.
23func layers(v string) []string {
24 var out []string
25 depth, start := 0, 0
26 for i, r := range v {
27 switch r {
28 case '(':
29 depth++
30 case ')':
31 depth--
32 case ',':
33 if depth == 0 {
34 out = append(out, strings.TrimSpace(v[start:i]))
35 start = i + 1
36 }
37 }
38 }
39 return append(out, strings.TrimSpace(v[start:]))
40}
41
42func isGrey(rgb string) bool {
43 var r, g, b float64
44 if _, err := fmt.Sscanf(rgb, "rgb(%f, %f, %f)", &r, &g, &b); err != nil {
45 return false
46 }
47 return math.Abs(r-g) < 8 && math.Abs(g-b) < 8 && math.Abs(r-b) < 8
48}
49
50// TestEveryButtonVariantHasItsOwnHoverAndPress is the C11 table read back
51// from the browser, frozen first (C14): rest, hover and press differ for
52// every variant in both spellings; a filled hover keeps its fill and
53// never turns grey (the mockup regression); the sheen is on only while
54// hovered and not pressed; nothing hovers or presses while disabled or
55// busy; the class and attribute spellings compute the same.
56func TestEveryButtonVariantHasItsOwnHoverAndPress(t *testing.T) {
57 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
58 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
59 defer cancel()
60 open(t, ctx, rig, "/", false)
61 still(t, ctx)
62 type face struct{ bg, img, border, color, shadow string }
63 read := func(id string) face {
64 return face{computed(t, ctx, id, "background-color"), computed(t, ctx, id, "background-image"), computed(t, ctx, id, "border-top-color"), computed(t, ctx, id, "color"), computed(t, ctx, id, "box-shadow")}
65 }
66 for _, id := range []string{"#b-plain", "#b-primary", "#b-danger", "#b-ghost", "#b-link"} {
67 rest := read(id)
68 forceState(t, ctx, id, "hover")
69 hover := read(id)
70 forceState(t, ctx, id, "hover", "active")
71 press := read(id)
72 forceState(t, ctx, id)
73 if rest == hover {
74 t.Errorf("%s: hover is the rest face %+v", id, rest)
75 }
76 if hover == press {
77 t.Errorf("%s: press is the hover face %+v; a pressed face replaces the hovered face (C11)", id, hover)
78 }
79 if id == "#b-primary" || id == "#b-danger" || id == "#b-link" {
80 if hover.bg == rest.bg {
81 t.Errorf("%s: hover keeps the rest fill %s; every state reads its own token", id, rest.bg)
82 }
83 if isGrey(hover.bg) {
84 t.Errorf("%s: hovered fill %s is grey; a filled button's hover keeps its fill", id, hover.bg)
85 }
86 if !strings.Contains(hover.img, "radial-gradient(120px") {
87 t.Errorf("%s: hover has no 120px sheen: %q", id, hover.img)
88 }
89 }
90 if id == "#b-plain" && !strings.Contains(hover.img, "radial-gradient(90px") {
91 t.Errorf("#b-plain: hover has no 90px sheen: %q", hover.img)
92 }
93 if strings.Contains(press.img, "radial-gradient(90px") || strings.Contains(press.img, "radial-gradient(120px") {
94 t.Errorf("%s: the sheen is still on while pressed: %q", id, press.img)
95 }
96 }
97 for _, pair := range [][2]string{{"#b-plain", "#b-plain-c"}, {"#b-primary", "#b-primary-c"}} {
98 for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}} {
99 forceState(t, ctx, pair[0], states...)
100 forceState(t, ctx, pair[1], states...)
101 if a, b := read(pair[0]), read(pair[1]); a != b {
102 t.Errorf("%s and %s differ in state %v: %+v vs %+v; both spellings are one rule (C12)", pair[0], pair[1], states, a, b)
103 }
104 forceState(t, ctx, pair[0])
105 forceState(t, ctx, pair[1])
106 }
107 }
108 // A disabled or busy button hovers like any other (today's
109 // behaviour in every theme) and never presses: the press rule
110 // excludes it, and a browser never makes a disabled button :active.
111 // The sheen is not compared: it is keyed on :not(:active), which a
112 // forced state can set on a disabled button where a pointer cannot.
113 for _, id := range []string{"#b-off", "#b-busy"} {
114 forceState(t, ctx, id, "hover")
115 hover := read(id)
116 forceState(t, ctx, id, "hover", "active")
117 got := read(id)
118 forceState(t, ctx, id)
119 hover.img, got.img = "", ""
120 if got != hover {
121 t.Errorf("%s pressed while hovered: %+v -> %+v; a disabled or busy button has no press", id, hover, got)
122 }
123 }
124}
125
126// cssColour spells a theme token the way Chromium serialises a computed
127// colour: a hex as rgb(r, g, b), an rgba() with its alpha as the
128// shortest decimal, transparent as rgba(0, 0, 0, 0). channelsOf and
129// alphaOf are contrast_face_test.go's.
130func cssColour(t *testing.T, v string) string {
131 t.Helper()
132 v = strings.TrimSpace(v)
133 if v == "transparent" {
134 return "rgba(0, 0, 0, 0)"
135 }
136 c, a := channelsOf(t, v), alphaOf(t, v)
137 if a >= 1 {
138 return fmt.Sprintf("rgb(%d, %d, %d)", int(c[0]), int(c[1]), int(c[2]))
139 }
140 return fmt.Sprintf("rgba(%d, %d, %d, %s)", int(c[0]), int(c[1]), int(c[2]), strconv.FormatFloat(a, 'f', -1, 64))
141}
142
143// expectedStack is the four-layer background-image the C11 table says
144// a button paints, in Chromium's computed serialisation (measured, not
145// guessed: a 70px radial at the default position serialises without
146// the position, a colour stop without a position as the colour alone,
147// calc(1 * 0.16) as 0.16). sheen is "" for none.
148func expectedStack(sheen, spotAlpha, washAlpha, face string) string {
149 if sheen == "" {
150 sheen = "none"
151 }
152 return sheen + ", radial-gradient(70px, rgba(0, 0, 0, " + spotAlpha + "), rgba(0, 0, 0, 0) 75%), linear-gradient(rgba(0, 0, 0, " + washAlpha + ") 0px, rgba(0, 0, 0, " + washAlpha + ") 0px), " + face
153}
154
155func alphaStr(a float64) string { return strconv.FormatFloat(a, 'f', -1, 64) }
156
157// TestEveryButtonPaintsItsWholeStack: for primary, danger
158// and plain, at rest, hovered and pressed, in both schemes, the resolved
159// background-image is exactly the four layers the table composes from
160// day's tokens, the background-colour is the state's fill and the
161// colour its label, so a dropped declaration (a shorter list, a face
162// that is not last) or a disconnected token (a colour that is not the
163// theme's) is caught in every cell the contrast arithmetic assumes. The
164// negative control severs the danger face in dark, hovered, and proves
165// the check fails there.
166func TestEveryButtonPaintsItsWholeStack(t *testing.T) {
167 for _, scheme := range []string{"light", "dark"} {
168 t.Run(scheme, func(t *testing.T) {
169 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
170 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
171 defer cancel()
172 open(t, ctx, rig, "/", false)
173 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil))
174 still(t, ctx)
175 tok := resolvedTokens(t, "day")[scheme]
176 wash, spot := pressNumber(t, "day", scheme, "--rst-press-wash"), pressNumber(t, "day", scheme, "--rst-press-spot")
177 washP, spotP := pressNumber(t, "day", scheme, "--rst-press-wash-plain"), pressNumber(t, "day", scheme, "--rst-press-spot-plain")
178 face := "linear-gradient(" + cssColour(t, "rgba(255, 255, 255, "+tok["--rst-depth-face"]+")") + ", " + cssColour(t, "rgba(0, 0, 0, "+tok["--rst-depth-face"]+")") + ")"
179 sheenFill := "radial-gradient(120px at 50% -40%, " + cssColour(t, tok["--rst-sheen-fill"]) + ", rgba(0, 0, 0, 0) 70%)"
180 sheenPlain := "radial-gradient(90px at 50% -40%, " + cssColour(t, tok["--rst-sheen-plain"]) + ", rgba(0, 0, 0, 0) 70%)"
181 type cell struct {
182 sel string
183 states []string
184 stack, bg, fg string
185 }
186 cells := []cell{
187 {"#b-primary", nil, expectedStack("", "0", "0", face), cssColour(t, tok["--rst-accent-fill"]), cssColour(t, tok["--rst-on-accent-fill"])},
188 {"#b-primary", []string{"hover"}, expectedStack(sheenFill, "0", "0", face), cssColour(t, tok["--rst-accent-fill-hover"]), cssColour(t, tok["--rst-on-accent-fill"])},
189 {"#b-primary", []string{"hover", "active"}, expectedStack("", alphaStr(spot), alphaStr(wash), face), cssColour(t, tok["--rst-accent-fill-hover"]), cssColour(t, tok["--rst-on-accent-fill"])},
190 {"#b-danger", nil, expectedStack("", "0", "0", face), cssColour(t, tok["--rst-danger-fill"]), cssColour(t, tok["--rst-on-danger-fill"])},
191 {"#b-danger", []string{"hover"}, expectedStack(sheenFill, "0", "0", face), cssColour(t, tok["--rst-danger-fill-hover"]), cssColour(t, tok["--rst-on-danger-fill-hover"])},
192 {"#b-danger", []string{"hover", "active"}, expectedStack("", alphaStr(spot), alphaStr(wash), face), cssColour(t, tok["--rst-danger-fill-hover"]), cssColour(t, tok["--rst-on-danger-fill-hover"])},
193 {"#b-plain", nil, expectedStack("", "0", "0", "none"), cssColour(t, tok["--rst-control-face"]), cssColour(t, tok["--rst-text"])},
194 {"#b-plain", []string{"hover"}, expectedStack(sheenPlain, "0", "0", "none"), cssColour(t, tok["--rst-control-face-hover"]), cssColour(t, tok["--rst-control-text-hover"])},
195 {"#b-plain", []string{"hover", "active"}, expectedStack("", alphaStr(spotP), alphaStr(washP), "none"), cssColour(t, tok["--rst-control-face-hover"]), cssColour(t, tok["--rst-control-text-hover"])},
196 // A ghost hovers with no sheen (the C11 ghost row): the plain
197 // sheen rule is :hover:not(:active) at (0,3,0), and a ghost
198 // rule at (0,2,0) once lost to it and painted the accent.
199 {"#b-ghost", []string{"hover"}, expectedStack("", "0", "0", "none"), cssColour(t, tok["--rst-ghost-face-hover"]), cssColour(t, tok["--rst-text"])},
200 }
201 for _, c := range cells {
202 forceState(t, ctx, c.sel, c.states...)
203 got, bg, fg := computed(t, ctx, c.sel, "background-image"), computed(t, ctx, c.sel, "background-color"), computed(t, ctx, c.sel, "color")
204 forceState(t, ctx, c.sel)
205 if norm(got) != norm(c.stack) {
206 t.Errorf("%s %v stack:\n got %s\n want %s", c.sel, c.states, got, c.stack)
207 }
208 if bg != c.bg || fg != c.fg {
209 t.Errorf("%s %v is %s on %s, want %s on %s", c.sel, c.states, fg, bg, c.fg, c.bg)
210 }
211 }
212 if scheme == "dark" {
213 // The negative control: sever the danger face and a cell the
214 // loop above accepted must now fail.
215 mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = document.createElement("style"); s.textContent = "[rst-btn~=danger] { --rst-btn-face: none; }"; document.head.append(s); return true; })()`, nil))
216 forceState(t, ctx, "#b-danger", "hover")
217 cut := computed(t, ctx, "#b-danger", "background-image")
218 forceState(t, ctx, "#b-danger")
219 if norm(cut) == norm(expectedStack(sheenFill, "0", "0", face)) {
220 t.Fatal("severing the danger face left its hovered stack unchanged; this check would not catch a dropped declaration")
221 }
222 }
223 })
224 }
225}
226
227// norm collapses whitespace differences in a serialised gradient.
228func norm(s string) string { return strings.Join(strings.Fields(s), " ") }
229
230// TestAPressIsInstantAndSlowToLeave is the press's drive: --rst-press is 1 and
231// the button sits 1px lower while held, with the same box; on release
232// the fade is a 0.7s transition on the spec's curve, and 250ms into it
233// the value is what the curve gives (about 0.223); under reduced motion
234// it is 0 at once and the button never moved. A real pointer, not a
235// forced state: the fade is a transition, and a forced pseudo-state
236// does not run one on removal. Not frozen, for the same reason. The
237// transition is read through getAnimations(), paused and scrubbed to
238// 250ms, not sampled by the clock: a sleep-then-read had about 110ms
239// of slack on a slow runner (0.11 at 350ms, under the old floor of
240// 0.1), and the scrub still catches a snap (no transition), a stick,
241// and a wrong duration or curve.
242func TestAPressIsInstantAndSlowToLeave(t *testing.T) {
243 for _, reduce := range []bool{false, true} {
244 t.Run(fmt.Sprintf("reduced motion %v", reduce), func(t *testing.T) {
245 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
246 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
247 defer cancel()
248 open(t, ctx, rig, "/", reduce)
249 x, y, w, h := rect(t, ctx, "#b-primary")
250 cx, cy := x+w/2, y+h/2
251 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
252 if got := computed(t, ctx, "#b-primary", "--rst-press"); got != "1" {
253 t.Fatalf("held: --rst-press is %q, want 1", got)
254 }
255 if got := computed(t, ctx, "#b-primary", "translate"); (got == "0px 1px") == reduce {
256 t.Errorf("held: translate is %q (reduced motion %v)", got, reduce)
257 }
258 if _, _, w2, h2 := rect(t, ctx, "#b-primary"); w2 != w || h2 != h {
259 t.Errorf("the pressed button's box changed from %vx%v to %vx%v; a press must cause no layout shift", w, h, w2, h2)
260 }
261 mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
262 if reduce {
263 if got := computed(t, ctx, "#b-primary", "--rst-press"); got != "0" {
264 t.Errorf("released under reduced motion: --rst-press is %q, want 0 at once", got)
265 }
266 return
267 }
268 var fade struct {
269 Found bool
270 Duration float64
271 Easing string
272 Value string
273 }
274 at(t, ctx, `(() => { const b = document.querySelector("#b-primary"); const a = b.getAnimations().find(x => x.transitionProperty === "--rst-press"); if (!a) return JSON.stringify({Found: false}); const tm = a.effect.getTiming(); a.pause(); a.currentTime = 250; return JSON.stringify({Found: true, Duration: tm.duration, Easing: tm.easing, Value: getComputedStyle(b).getPropertyValue("--rst-press")}); })()`, &fade)
275 if !fade.Found {
276 t.Fatal("released: no transition is running on --rst-press; the press snapped instead of fading")
277 }
278 if fade.Duration != 700 || fade.Easing != "cubic-bezier(0.2, 0.6, 0.3, 1)" {
279 t.Errorf("the fade runs %vms on %q, want 700ms on cubic-bezier(0.2, 0.6, 0.3, 1)", fade.Duration, fade.Easing)
280 }
281 var v float64
282 if _, err := fmt.Sscanf(fade.Value, "%f", &v); err != nil {
283 t.Fatalf("--rst-press at 250ms is %q: %v", fade.Value, err)
284 }
285 if math.Abs(v-0.223) > 0.005 {
286 t.Errorf("250ms into the fade --rst-press is %v, want about 0.223: the curve from 1 to 0 over 0.7s gives that, a stuck or linear fade does not", v)
287 }
288 })
289 }
290}
291
292// TestAPressInsideTheFadeSnapsBackToOne (review focus 2).
293func TestAPressInsideTheFadeSnapsBackToOne(t *testing.T) {
294 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
295 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
296 defer cancel()
297 open(t, ctx, rig, "/", false)
298 x, y, w, h := rect(t, ctx, "#b-plain")
299 cx, cy := x+w/2, y+h/2
300 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)),
301 chromedp.MouseEvent(input.MouseReleased, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)),
302 chromedp.Sleep(200*time.Millisecond),
303 chromedp.MouseEvent(input.MousePressed, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
304 if got := computed(t, ctx, "#b-plain", "--rst-press"); got != "1" {
305 t.Errorf("pressed again mid-fade: --rst-press is %q, want 1 at once", got)
306 }
307 mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
308}
309
310// TestAGhostsReleaseShadowEases (#104 item 6): the ghost's press shadow
311// rides --rst-press, so on release it eases over the same 0.7s curve
312// as the face. Held, the ghost's own :active shadow is a 1px-down, 3px
313// inset of the contact colour; 250ms into the release, --rst-press is
314// about 0.223 and the shadow is that fraction of it, still inset, one
315// layer. A base-rule shadow in its place (the rest list, not inset) is
316// what the guarded line prevents, and a snap to nothing is what the
317// transition prevents. Scrubbed, not sampled, as the press drive is.
318func TestAGhostsReleaseShadowEases(t *testing.T) {
319 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
320 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
321 defer cancel()
322 open(t, ctx, rig, "/", false)
323 x, y, w, h := rect(t, ctx, "#b-ghost")
324 cx, cy := x+w/2, y+h/2
325 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
326 held := computed(t, ctx, "#b-ghost", "box-shadow")
327 if !strings.HasSuffix(held, " 0px 1px 3px 0px inset") || strings.Contains(held, "inset, ") {
328 t.Fatalf("held: the ghost's shadow is %q, want one inset layer 0px 1px 3px", held)
329 }
330 mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
331 var fade struct {
332 Found bool
333 Press string
334 Shadow string
335 }
336 at(t, ctx, `(() => { const b = document.querySelector("#b-ghost"); const a = b.getAnimations().find(x => x.transitionProperty === "--rst-press"); if (!a) return JSON.stringify({Found: false}); a.pause(); a.currentTime = 250; const cs = getComputedStyle(b); return JSON.stringify({Found: true, Press: cs.getPropertyValue("--rst-press"), Shadow: cs.boxShadow}); })()`, &fade)
337 if !fade.Found {
338 t.Fatal("released: no transition is running on --rst-press; the ghost's shadow snapped instead of easing")
339 }
340 var press, sy, blur float64
341 fmt.Sscanf(fade.Press, "%f", &press)
342 m := regexp.MustCompile(`\) 0px ([\d.]+)px ([\d.]+)px 0px inset$`).FindStringSubmatch(fade.Shadow)
343 if m == nil || strings.Contains(fade.Shadow, "inset, ") {
344 t.Fatalf("250ms into the release the ghost's shadow is %q (--rst-press %s); want one inset layer riding the press", fade.Shadow, fade.Press)
345 }
346 fmt.Sscanf(m[1], "%f", &sy)
347 fmt.Sscanf(m[2], "%f", &blur)
348 if math.Abs(sy-press) > 0.02 || math.Abs(blur-3*press) > 0.05 {
349 t.Errorf("250ms into the release the ghost's shadow is 0 %vpx %vpx with --rst-press %v; want the press's 1px and 3px scaled by it", sy, blur, press)
350 }
351 if press < 0.1 || press > 0.4 {
352 t.Errorf("250ms into the release --rst-press is %v, want about 0.223; the shadow was read off the wrong point of the curve", press)
353 }
354}
355
356// TestTheFaceStillPaintsWithoutARegisteredPress (#104 item 6): the
357// button's face and the ghost's shadow read --rst-press through a 0
358// fallback, because an engine without @property has no initial value
359// for it, and an unset var() inside a gradient or a calc() invalidates
360// the whole declaration: four layers of face gone, shadow gone. The
361// drive unregisters the property by serving tokens.css without its
362// @property block, then checks every variant still paints its face at
363// rest and pressed, and the ghost its shadow while pressed. Reverting
364// a fallback on either guarded line turns that declaration to none.
365func TestTheFaceStillPaintsWithoutARegisteredPress(t *testing.T) {
366 const registration = `@property --rst-press { syntax: "<number>"; inherits: false; initial-value: 0; }`
367 rig := pressFallbackRig(t, func(css string) string {
368 if strings.Count(css, registration) != 1 {
369 t.Fatalf("tokens.css no longer registers --rst-press as this test knows it:\n%s", registration)
370 }
371 return strings.Replace(css, registration, "/* --rst-press unregistered by the test */", 1)
372 }, harness.WithHover())
373 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
374 defer cancel()
375 open(t, ctx, rig, "/", false)
376 // The control: unregistered, the property has no computed value at
377 // rest. Registered, it reads 0.
378 if got := computed(t, ctx, "#b-plain", "--rst-press"); got != "" {
379 t.Fatalf("--rst-press computes to %q at rest; the property is still registered, so the drive would prove nothing", got)
380 }
381 for _, id := range []string{"#b-plain", "#b-primary", "#b-danger", "#b-ghost", "#b-link"} {
382 if got := computed(t, ctx, id, "background-image"); got == "none" || len(layers(got)) != 4 {
383 t.Errorf("%s at rest: background-image is %q, want four layers; the face is gone without the registered press", id, got)
384 }
385 }
386 x, y, w, h := rect(t, ctx, "#b-ghost")
387 cx, cy := x+w/2, y+h/2
388 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
389 if got := computed(t, ctx, "#b-ghost", "box-shadow"); got == "none" || !strings.HasSuffix(got, " 0px 1px 3px 0px inset") {
390 t.Errorf("the ghost pressed: box-shadow is %q, want its 0px 1px 3px inset; the shadow is gone without the registered press", got)
391 }
392 if got := computed(t, ctx, "#b-ghost", "background-image"); got == "none" || len(layers(got)) != 4 {
393 t.Errorf("the ghost pressed: background-image is %q, want four layers", got)
394 }
395 mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, cx, cy, chromedp.ButtonLeft, chromedp.ClickCount(1)))
396 // Released with no registered property there is no transition to
397 // ease; the shadow goes to its zero-sized rest, and must not go to
398 // none.
399 if got := computed(t, ctx, "#b-ghost", "box-shadow"); got == "none" || !strings.HasSuffix(got, " 0px 0px 0px 0px inset") {
400 t.Errorf("the ghost released: box-shadow is %q, want its zero inset; the shadow is gone without the registered press", got)
401 }
402}
403
404// TestHoverTintsPaintWithoutRegisteredNumbers (from rastrillo's day
405// follow-ups review): the row, tile and rail hover tints read --rst-hover and
406// --rst-pressed, and in an engine without @property (Firefox before
407// 128) an unset var() invalidates the whole declaration at computed
408// value time: the hover rule sets --rst-hover itself, but --rst-pressed
409// was set only while pressed, so a plain hover lost its background
410// (rows and rails their tint, the tile its surface) and a release lost
411// it again. Both registrations are cut from the served tokens.css; the
412// hover rule must then set every number it reads, and rest, hover,
413// press and release must each paint the registered design's settled
414// values. Frozen, so a settled value is what is read.
415func TestHoverTintsPaintWithoutRegisteredNumbers(t *testing.T) {
416 registrations := []string{
417 `@property --rst-hover { syntax: "<percentage>"; inherits: false; initial-value: 0%; }`,
418 `@property --rst-pressed { syntax: "<percentage>"; inherits: false; initial-value: 0%; }`,
419 }
420 rig := pressFallbackRig(t, func(css string) string {
421 for _, r := range registrations {
422 if strings.Count(css, r) != 1 {
423 t.Fatalf("tokens.css no longer registers the hover numbers as this test knows them:\n%s", r)
424 }
425 css = strings.Replace(css, r, "/* unregistered by the test */", 1)
426 }
427 return css
428 }, harness.WithHover())
429 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
430 defer cancel()
431 open(t, ctx, rig, "/", false)
432 still(t, ctx)
433 // The control: unregistered, the numbers have no computed value at
434 // rest. Registered, they read 0%.
435 if h, p := computed(t, ctx, "#row-a", "--rst-hover"), computed(t, ctx, "#row-a", "--rst-pressed"); h != "" || p != "" {
436 t.Fatalf("--rst-hover is %q and --rst-pressed %q at rest; the properties are still registered, so the drive would prove nothing", h, p)
437 }
438 const hoverTint, pressTint = "rgb(245, 247, 253)", "rgb(236, 241, 252)"
439 for _, c := range []struct{ sel, press, rest string }{
440 {"#row-a", "#link-a", "rgba(0, 0, 0, 0)"},
441 {"#tile", "#tile-link", "rgb(255, 255, 255)"},
442 {"#nav-x", "#nav-x", "rgba(0, 0, 0, 0)"},
443 } {
444 read := func() (string, string) {
445 return computed(t, ctx, c.sel, "background-color"), computed(t, ctx, c.sel, "box-shadow")
446 }
447 if bg, _ := read(); bg != c.rest {
448 t.Errorf("%s at rest is %s, want %s", c.sel, bg, c.rest)
449 }
450 forceState(t, ctx, c.sel, "hover")
451 if bg, sh := read(); bg != hoverTint || !strings.Contains(sh, "inset") {
452 t.Errorf("%s hovered is %s with box-shadow %q; want the hover tint %s with its inset lift. Without @property an unset number blanks the declaration", c.sel, bg, sh, hoverTint)
453 }
454 if c.press == c.sel {
455 forceState(t, ctx, c.sel, "hover", "active")
456 } else {
457 forceState(t, ctx, c.press, "active")
458 }
459 if bg, sh := read(); bg != pressTint || sh == "none" {
460 t.Errorf("%s pressed is %s with box-shadow %q; want the press tint %s with a shadow", c.sel, bg, sh, pressTint)
461 }
462 if c.press == c.sel {
463 forceState(t, ctx, c.sel, "hover")
464 } else {
465 forceState(t, ctx, c.press)
466 }
467 if bg, sh := read(); bg != hoverTint || !strings.Contains(sh, "inset") {
468 t.Errorf("%s released, still hovered, is %s with box-shadow %q; want the hover tint %s again", c.sel, bg, sh, hoverTint)
469 }
470 forceState(t, ctx, c.sel)
471 if bg, _ := read(); bg != c.rest {
472 t.Errorf("%s back at rest is %s, want %s", c.sel, bg, c.rest)
473 }
474 }
475}
476
477// TestFocusOutlinesSurviveLiftAndPress (C14, review focus 4): the solid
478// 2px outline is computed on a focused button that is also hovered and
479// pressed, on a focused link in a hovered row, and on a focused hovered
480// nav item. One forceState call per element with every state it needs.
481// It passes before the press and the lift exist (the outline is the
482// page's today): it is here so no later state can take it away.
483func TestFocusOutlinesSurviveLiftAndPress(t *testing.T) {
484 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
485 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
486 defer cancel()
487 open(t, ctx, rig, "/", false)
488 still(t, ctx)
489 forceState(t, ctx, "#b-primary", "hover", "active", "focus", "focus-visible")
490 if st, w := computed(t, ctx, "#b-primary", "outline-style"), computed(t, ctx, "#b-primary", "outline-width"); st != "solid" || w != "2px" {
491 t.Errorf("a focused, hovered, pressed button has outline %s %s, want solid 2px", st, w)
492 }
493 forceState(t, ctx, "#b-primary")
494 forceState(t, ctx, "#row-a", "hover")
495 forceState(t, ctx, "#link-a", "focus", "focus-visible")
496 var ring string
497 mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = getComputedStyle(document.querySelector("#link-a"), "::after"); return s.outlineStyle + " " + s.outlineWidth; })()`, &ring))
498 if ring != "solid 2px" {
499 t.Errorf("a focused link in a hovered row draws %q on its overlay, want solid 2px", ring)
500 }
501 forceState(t, ctx, "#row-a")
502 forceState(t, ctx, "#link-a")
503 forceState(t, ctx, "#nav-x", "hover", "focus", "focus-visible")
504 if st, w := computed(t, ctx, "#nav-x", "outline-style"), computed(t, ctx, "#nav-x", "outline-width"); st != "solid" || w != "2px" {
505 t.Errorf("a focused hovered nav item has outline %s %s, want solid 2px", st, w)
506 }
507 forceState(t, ctx, "#nav-x")
508}
509
510// TestCardsRestOnThePageWithNoScript: with no light.js on the page, a
511// box, a stats band, a list card and a data card each compute the two
512// ambient layers lit from straight above, a 0px rim radius (the static
513// design), and --rst-card-lit 0. Depth adds no height: the same box is
514// measured again with its decorative tokens set flat on the element,
515// which changes shadows and gradients and nothing in layout.
516func TestCardsRestOnThePageWithNoScript(t *testing.T) {
517 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
518 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
519 defer cancel()
520 open(t, ctx, rig, "/", false)
521 still(t, ctx)
522 for _, id := range []string{"#box", "#stats", "#list", "#card"} {
523 if got := computed(t, ctx, id, "--rst-card-lit"); got != "0" {
524 t.Errorf("%s: --rst-card-lit is %q with no script, want 0", id, got)
525 }
526 shadow := computed(t, ctx, id, "box-shadow")
527 if !strings.HasPrefix(shadow, "rgba(16, 24, 40, 0.06) 0px 1px 1px 0px, rgba(16, 24, 40, 0.06) 0px 1.5px 3px 0px") {
528 t.Errorf("%s: box-shadow %q, want the two ambient layers lit from straight above", id, shadow)
529 }
530 if img := computed(t, ctx, id, "background-image"); !strings.Contains(img, "radial-gradient(0px") {
531 t.Errorf("%s: the rim gradient is %q; at lit 0 its radius is 0px", id, img)
532 }
533 }
534 var heights struct{ Lit, Flat float64 }
535 at(t, ctx, `(() => { const b = document.getElementById("box"); const lit = b.getBoundingClientRect().height; b.style.cssText = "--rst-depth-ambient: transparent; --rst-light-rim: var(--rst-line); --rst-card-lit: 1"; const flat = b.getBoundingClientRect().height; b.style.cssText = ""; return JSON.stringify({Lit: lit, Flat: flat}); })()`, &heights)
536 if heights.Lit != heights.Flat {
537 t.Errorf("a box is %vpx tall with depth and %vpx without; depth added height", heights.Lit, heights.Flat)
538 }
539}
540
541// TestEveryNavSurfaceHasItsStates is the table above read back from
542// the browser, frozen: the sidebar's and console's current item is a
543// surface thumb with a ring and a contact shadow on a rail that sits on
544// the page background (C15), on an index-view root at desktop width
545// too, where the rail sits beside the page; a hovered item takes the
546// hover tint and a pressed one the press tint, in both rails and the
547// topbar's phone card; a pressed tab or chip is its hovered self; every
548// surface has its tap highlight off.
549func TestEveryNavSurfaceHasItsStates(t *testing.T) {
550 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
551 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
552 defer cancel()
553 open(t, ctx, rig, "/", false)
554 still(t, ctx)
555 for _, c := range []struct{ rail, cur, other string }{{"#sidebar [rst-shell-rail]", "#nav-y", "#nav-x"}, {"#console [rst-shell-rail]", "#cnav-y", "#cnav-x"}, {"#isidebar [rst-shell-rail]", "#inav-y", "#inav-x"}} {
556 if bg, rail := computed(t, ctx, c.cur, "background-color"), computed(t, ctx, c.rail, "background-color"); bg != "rgb(255, 255, 255)" || rail != "rgb(251, 251, 252)" {
557 t.Errorf("%s: current item %s on a rail of %s; want the surface thumb on the page background", c.cur, bg, rail)
558 }
559 if sh := computed(t, ctx, c.cur, "box-shadow"); !strings.Contains(sh, "inset") || strings.Count(sh, "rgb") < 3 {
560 t.Errorf("%s: box-shadow %q, want the highlight, the ring and the contact shadow", c.cur, sh)
561 }
562 rest := computed(t, ctx, c.other, "background-color")
563 forceState(t, ctx, c.other, "hover")
564 hover := computed(t, ctx, c.other, "background-color")
565 forceState(t, ctx, c.other, "hover", "active")
566 press := computed(t, ctx, c.other, "background-color")
567 forceState(t, ctx, c.other)
568 if rest == hover || hover != "rgb(245, 247, 253)" || press != "rgb(236, 241, 252)" {
569 t.Errorf("%s rest %s hover %s press %s; want rgb(245, 247, 253) and rgb(236, 241, 252)", c.other, rest, hover, press)
570 }
571 }
572 // The topbar's desktop links keep the underline; the phone card is
573 // measured on its own rig at the end.
574 if got := computed(t, ctx, "#tnav-y", "border-bottom-color"); got != "rgb(31, 86, 214)" {
575 t.Errorf("topbar current link underline is %s, want the accent", got)
576 }
577 // The tab and chip hovers are top-level rules, outside any hover
578 // query, so they read the same on a rig with or without a pointer.
579 for _, sel := range []string{"#tab-1", "#page-1"} {
580 forceState(t, ctx, sel, "hover")
581 hover := computed(t, ctx, sel, "background-color")
582 forceState(t, ctx, sel, "hover", "active")
583 if got := computed(t, ctx, sel, "background-color"); got != hover {
584 t.Errorf("%s pressed is %s, hovered %s; tabs and chips have no press of their own", sel, got, hover)
585 }
586 forceState(t, ctx, sel)
587 }
588 forceState(t, ctx, "#nav-x", "active")
589 if got := computed(t, ctx, "#nav-x", "background-color"); got != "rgb(236, 241, 252)" {
590 t.Errorf("nav item tapped (active, no hover) is %s, want the tap tint rgb(236, 241, 252)", got)
591 }
592 forceState(t, ctx, "#nav-x")
593 for _, sel := range []string{"#nav-x", "#cnav-x", "#tnav-x", "#tab-1", "#page-1"} {
594 if got := computed(t, ctx, sel, "-webkit-tap-highlight-color"); got != "rgba(0, 0, 0, 0)" {
595 t.Errorf("%s tap highlight is %s, want transparent", sel, got)
596 }
597 }
598 // Chromium serialises a zero letter-spacing as "normal", whatever
599 // length the theme wrote, so both spellings of none are accepted;
600 // the old 0.06em reads 0.69px and fails either way.
601 if size, track := computed(t, ctx, "[rst-stat-label]", "letter-spacing"), computed(t, ctx, "[rst-stat-label]", "text-transform"); (size != "0px" && size != "normal") || track != "none" {
602 t.Errorf("stat label letter-spacing %s text-transform %s; want 0px and none (C15)", size, track)
603 }
604 // The phone card, on a rig opened at the phone width: after a second
605 // EmulateViewport this Chromium reports (hover: none) and (pointer:
606 // none), so a card read on the rig above would see only the tap
607 // rule. One viewport call before navigation keeps the mouse, and the
608 // query is checked first (harness/rig.go says to). Day hovers to its
609 // card tint and presses to the tap tint, and its current link wears
610 // the thumb's three shadow layers; plain and signal, swapped in on
611 // the same page, paint nothing on hover or press, as they did before
612 // the card had a hover.
613 narrow := sizingRig(t, false, depthDoc(t), harness.WithHover())
614 nctx, ncancel := context.WithTimeout(narrow.Context(), 60*time.Second)
615 defer ncancel()
616 mustRun(t, nctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(narrow.Origin+"/"), chromedp.WaitVisible("#field", chromedp.ByQuery))
617 still(t, nctx)
618 var hov bool
619 at(t, nctx, `JSON.stringify(matchMedia("(hover: hover)").matches)`, &hov)
620 if !hov {
621 t.Fatal("(hover: hover) is false on the narrow rig; the card's hover rules cannot be read here")
622 }
623 cardFace := func(states ...string) string {
624 forceState(t, nctx, "#tnav-x", states...)
625 got := computed(t, nctx, "#tnav-x", "background-color")
626 forceState(t, nctx, "#tnav-x")
627 return got
628 }
629 if hover, press := cardFace("hover"), cardFace("hover", "active"); hover != "rgb(245, 247, 253)" || press != "rgb(236, 241, 252)" {
630 t.Errorf("topbar phone-card link hover %s press %s; want rgb(245, 247, 253) and rgb(236, 241, 252)", hover, press)
631 }
632 if bg, sh := computed(t, nctx, "#tnav-y", "background-color"), computed(t, nctx, "#tnav-y", "box-shadow"); bg != "rgb(255, 255, 255)" || !strings.Contains(sh, "inset") || strings.Count(sh, "rgb") < 3 {
633 t.Errorf("topbar phone-card current link is %s with box-shadow %q; want the surface thumb with the highlight, the ring and the contact shadow", bg, sh)
634 }
635 // The phone index's rows, on the same rig: they tint and nothing
636 // else. The rail items' state rules match these rows too and the
637 // index block outweighs them only by repeating the root's name, so
638 // these cells are what holds that ladder: weaken it (drop the
639 // doubled root) and the rail's ring and sink leak back as a shadow
640 // on a hovered or pressed row. Hover is the hover tint, press and tap
641 // the press tint (today's accent-soft in plain and signal, where the
642 // tap tint is transparent and would have blanked a tapped row), and a
643 // current row rests as any row, with no thumb.
644 face := func(sel string, states ...string) (bg, shadow string) {
645 forceState(t, nctx, sel, states...)
646 bg, shadow = computed(t, nctx, sel, "background-color"), computed(t, nctx, sel, "box-shadow")
647 forceState(t, nctx, sel)
648 return bg, shadow
649 }
650 indexRows := func(theme, hoverTint, pressTint string) {
651 for _, sel := range []string{"#inav-x", "#inav-y"} {
652 for _, c := range []struct {
653 states []string
654 want string
655 }{{[]string{"hover"}, hoverTint}, {[]string{"hover", "active"}, pressTint}, {[]string{"active"}, pressTint}} {
656 if bg, shadow := face(sel, c.states...); bg != c.want || shadow != "none" {
657 t.Errorf("%s: index row %s %v is %s with box-shadow %q; want %s and none (tint only)", theme, sel, c.states, bg, shadow, c.want)
658 }
659 }
660 }
661 restX, _ := face("#inav-x")
662 if bg, shadow := face("#inav-y"); bg != restX || shadow != "none" || computed(t, nctx, "#inav-y", "font-weight") != "450" {
663 t.Errorf("%s: the current index row rests as %s with box-shadow %q and weight %s; want a plain row (%s, none, 450), the index has no thumb", theme, bg, shadow, computed(t, nctx, "#inav-y", "font-weight"), restX)
664 }
665 }
666 indexRows("day", "rgb(245, 247, 253)", "rgb(236, 241, 252)")
667 for _, c := range []struct{ theme, cardHover, hoverTint, pressTint string }{{"plain", "rgba(0, 0, 0, 0)", "rgb(237, 237, 237)", "rgb(237, 237, 237)"}, {"signal", "rgba(0, 0, 0, 0)", "rgb(233, 238, 255)", "rgb(233, 238, 255)"}} {
668 css, _ := fiesta.ThemeCSS(c.theme)
669 mustRun(t, nctx, chromedp.Evaluate(`(() => { const l = document.querySelector('link[href="/theme.css"]'); if (l) l.remove(); for (const s of document.querySelectorAll("style[data-swap]")) s.remove(); const s = document.createElement("style"); s.dataset.swap = "1"; s.textContent = `+jsStr(string(css))+`; document.head.append(s); return true; })()`, nil))
670 if hover, press := cardFace("hover"), cardFace("hover", "active"); hover != c.cardHover || press != c.cardHover {
671 t.Errorf("%s: topbar phone-card link hover %s press %s; want transparent, as before the card had a hover", c.theme, hover, press)
672 }
673 indexRows(c.theme, c.hoverTint, c.pressTint)
674 }
675}
676
677// TestFieldsSitIntoThePageAndFocusKeepsTheOutline (spec §3):
678// an input has a 1px inset ambient shadow and a --rst-line-strong
679// border at rest; focused, its border is the accent, it gains the 3px
680// halo, and the solid 2px outline is still there. The textarea does
681// the same, and the search box does it on :focus-within, the field
682// box carrying the indicator for its input.
683func TestFieldsSitIntoThePageAndFocusKeepsTheOutline(t *testing.T) {
684 rig := sizingRig(t, false, depthDoc(t), harness.WithHover())
685 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
686 defer cancel()
687 open(t, ctx, rig, "/", false)
688 still(t, ctx)
689 for _, f := range []struct{ box, focus string }{{"#field", "#field"}, {"#area", "#area"}, {"#search", "#search-in"}} {
690 // day's light --rst-line-strong is #868691 (a review decision: the sign-in
691 // border needed it darker than the #8a8a95 first proposed).
692 if sh, border := computed(t, ctx, f.box, "box-shadow"), computed(t, ctx, f.box, "border-top-color"); !strings.HasPrefix(sh, "rgba(16, 24, 40, 0.06) 0px 1px 1px 0px inset") || border != "rgb(134, 134, 145)" {
693 t.Errorf("%s at rest: box-shadow %q border %s; want the inset ambient shadow and --rst-line-strong", f.box, sh, border)
694 }
695 forceState(t, ctx, f.focus, "focus", "focus-visible")
696 sh, border, outline := computed(t, ctx, f.box, "box-shadow"), computed(t, ctx, f.box, "border-top-color"), computed(t, ctx, f.box, "outline-style")+" "+computed(t, ctx, f.box, "outline-width")
697 if border != "rgb(31, 86, 214)" || !strings.Contains(sh, "0px 0px 0px 3px") || outline != "solid 2px" {
698 t.Errorf("focused %s: border %s box-shadow %q outline %s; want the accent border, the 3px halo and the solid 2px outline", f.box, border, sh, outline)
699 }
700 forceState(t, ctx, f.focus)
701 }
702}
703