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 touch-size drives from rastrillo's ui/sizing_browser_test.go at
4// the seed commit: the type step, the target floor, the icons inside
5// controls, the calendar's docking and the floor block's desktop
6// neutrality, each reading the sizing page through fixture so the
7// page it measures is the page the rastrillo drive measured. The
8// readers the drives share (readTargets, settleUntil, the type and
9// font readers) live in rig_browser_test.go.
10package check
11
12import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "math"
17 "net/http"
18 "strings"
19 "testing"
20 "time"
21
22 "github.com/chromedp/chromedp"
23 "github.com/chromedp/chromedp/kb"
24
25 "amadan.net/rastrillo/fiesta/internal/harness"
26)
27
28// sizingFixture is the seeded sizing page: every partial with its own
29// test data, every styleguide sample except the modal, and the extras
30// (small parents, the bare and primary inputs, the button scale, the
31// language menu, the short labels and the icon-and-text controls), as
32// rastrillo's sizingFixture built it. The modal is its own page: its overlay
33// is fixed over the whole viewport and would occlude every hit test on
34// this one.
35func sizingFixture(t *testing.T) string {
36 t.Helper()
37 return fixture(t, "sizing_fixture_test.go", "sizingFixture", "")
38}
39
40// modalSample is the modal the sizing drives measure on a page of its
41// own: the index names it as the example state that carries the
42// markup rastrillo's Styleguide()["modal"] held.
43func modalSample(t *testing.T, builder string) string {
44 t.Helper()
45 return fixtureExample(t, "sizing_browser_test.go", builder, "modal")
46}
47
48// sizingIcon is an icon as fiesta.Icon emits one: svg.icon, sized by
49// the control around it.
50const sizingIcon = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 12h16"/></svg>`
51
52// readType loads path at w×h and returns the token and field readings.
53func readType(t *testing.T, ctx context.Context, url string, w, h int64) (typeReading, []fontReading, float64) {
54 t.Helper()
55 var rawType, rawFonts string
56 var primary float64
57 if err := chromedp.Run(ctx,
58 chromedp.EmulateViewport(w, h),
59 chromedp.Navigate(url),
60 chromedp.WaitReady("body"),
61 chromedp.Evaluate(tokensJS, &rawType),
62 chromedp.Evaluate(fontsJS, &rawFonts),
63 chromedp.Evaluate(`parseFloat(getComputedStyle(document.getElementById("sizing-primary")).fontSize)`, &primary),
64 ); err != nil {
65 t.Fatalf("reading type at %dx%d: %v", w, h, err)
66 }
67 var tr typeReading
68 var fonts []fontReading
69 if err := json.Unmarshal([]byte(rawType), &tr); err != nil {
70 t.Fatal(err)
71 }
72 if err := json.Unmarshal([]byte(rawFonts), &fonts); err != nil {
73 t.Fatal(err)
74 }
75 // The small-parent fields and the bare and primary inputs are the
76 // cases this drive exists for; each is required by name, so losing
77 // one from the fixture fails here rather than shrinking the sweep.
78 named := map[string]bool{}
79 for _, f := range fonts {
80 named[f.Name] = true
81 }
82 for _, id := range []string{"sizing-bulk-search", "sizing-bulk-input", "sizing-bulk-note", "sizing-menu-search", "sizing-menu-input", "sizing-menu-note", "sizing-bare", "sizing-primary"} {
83 if !named[id] {
84 t.Fatalf("the sizing page has no %s; the fixture is not the one this drive measures", id)
85 }
86 }
87 return tr, fonts, primary
88}
89
90// TestTextControlsAreSixteenPixelsOnSmallOrTouchScreens is the type
91// half of §10.1 of the mobile ergonomics design spec
92// (docs/superpowers/specs/2026-09-30-mobile-ergonomics-design.md): at
93// 390 with touch, at 1024 with touch (the pointer half of the query
94// alone) and at 600 with a mouse (the width half alone), the four
95// tokens are one step up, every text-entry control is at least 16px —
96// including the ones in a bulk bar and a menu panel — and the primary
97// field is exactly --rst-fs-lg, 19px (the 1em bug gave 16).
98func TestTextControlsAreSixteenPixelsOnSmallOrTouchScreens(t *testing.T) {
99 pages := map[string]string{"/": sizingDoc("sizing", sizingFixture(t))}
100 for _, coarse := range []bool{true, false} {
101 rig := sizingRig(t, coarse, pages)
102 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
103 legs := []struct {
104 name string
105 w, h int64
106 }{{"600x800 mouse, the width half alone", 600, 800}}
107 if coarse {
108 legs = []struct {
109 name string
110 w, h int64
111 }{{"390x844 touch", 390, 844}, {"1024x768 touch, the pointer half alone", 1024, 768}}
112 }
113 for _, leg := range legs {
114 tr, fonts, primary := readType(t, ctx, rig.Origin+"/", leg.w, leg.h)
115 requirePointer(t, ctx, coarse)
116 // Each leg exists to exercise one half of the query: the 1024
117 // touch leg proves the pointer half only if the page really is
118 // wider than 40rem, and the 600 mouse leg the width half only if
119 // the pointer really is fine.
120 if tr.Width != int(leg.w) || tr.Coarse != coarse {
121 t.Fatalf("%s: the page read width %d, coarse %v; this leg needs width %d, coarse %v", leg.name, tr.Width, tr.Coarse, leg.w, coarse)
122 }
123 if tr.Lg != 19 || tr.Base != 16 || tr.Sm != 14 || tr.Xs != 13 || tr.Body != 16 {
124 t.Errorf("%s: tokens %v/%v/%v/%v body %v, want 19/16/14/13 body 16", leg.name, tr.Lg, tr.Base, tr.Sm, tr.Xs, tr.Body)
125 }
126 for _, f := range fonts {
127 if f.Px < 16 {
128 t.Errorf("%s: %s is %vpx; a phone zooms the page when it is focused", leg.name, f.Name, f.Px)
129 }
130 }
131 if primary != 19 {
132 t.Errorf("%s: the primary field is %vpx, want --rst-fs-lg (19px); 16 is the 1em trap", leg.name, primary)
133 }
134 }
135 cancel()
136 }
137}
138
139// TestDesktopDensityIsPinned is §10.1's 1280×900 mouse leg: every value
140// below is today's, measured, and any change fails, except the
141// deliberate desktop changes, pinned at their new values: the whole-row
142// target (§1.5), which TestTheWholeRowIsTheTarget holds, and the 32px
143// desktop floor with its 16px icons and 12px carets (2026-10-07), which
144// lifted the small button, the kebab and the row checkbox's label to 32,
145// and the even button scale (2026-10-09), which draws the small button
146// at 26 with its hit area at the floor. Desktop targets had ranged from 24 to 36px, sized one control at a
147// time; the floor is one number for all of them.
148func TestDesktopDensityIsPinned(t *testing.T) {
149 rig := sizingRig(t, false, map[string]string{"/": sizingDoc("sizing", sizingFixture(t)), "/modal": sizingDoc("modal", modalSample(t, "TestDesktopDensityIsPinned"))})
150 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
151 defer cancel()
152 tr, fonts, primary := readType(t, ctx, rig.Origin+"/", 1280, 900)
153 requirePointer(t, ctx, false)
154 if tr.Lg != 17 || tr.Base != 14 || tr.Sm != 12.5 || tr.Xs != 11.5 || tr.Body != 14 {
155 t.Errorf("desktop tokens %v/%v/%v/%v body %v, want 17/14/12.5/11.5 body 14", tr.Lg, tr.Base, tr.Sm, tr.Xs, tr.Body)
156 }
157 if primary != 17 {
158 t.Errorf("desktop primary field %vpx, want 17", primary)
159 }
160 // The control for the touch drive's "every field ≥ 16": on a desktop
161 // some field is under 16, so the instrument can see a small one.
162 small := false
163 for _, f := range fonts {
164 small = small || f.Px < 16
165 }
166 if !small {
167 t.Error("CONTROL: every text control is ≥16px on a 1280px desktop, so the touch drive's ≥16 reading proves nothing")
168 }
169 var raw string
170 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => {
171 const box = sel => { const r = document.querySelector(sel).getBoundingClientRect(); return [Math.round(r.width * 100) / 100, Math.round(r.height * 100) / 100]; };
172 return JSON.stringify({Sm: box("#sizing-btn-sm")[1], Md: box("#sizing-btn")[1], Lg: box("#sizing-btn-lg")[1],
173 Kebab: box('[data-sample="list-grid"] [rst-row-menu] > summary'), Label: box('[data-sample="selbox"] [rst-selbox]'),
174 Box: box('[data-sample="selbox"] [rst-selbox] input'),
175 Chip: box([...document.querySelectorAll("[rst-pagination] a")].filter(a => /^\d$/.test(a.textContent.trim())).map(a => "#" + (a.id || (a.id = "sizing-chip")))[0])});
176 })()`, &raw)); err != nil {
177 t.Fatal(err)
178 }
179 var d struct {
180 Sm, Md, Lg float64
181 Kebab, Label, Box [2]float64
182 Chip [2]float64
183 }
184 if err := json.Unmarshal([]byte(raw), &d); err != nil {
185 t.Fatal(err)
186 }
187 // Measured before the touch rules existed: 27.69, 33.88 and 43.97, a
188 // 26×26 kebab, a 16px checkbox in a 24×24 label. The floor then
189 // lifted the small button to 32, beside a 34px default, and the
190 // three sizes read as two. They are now an even scale, 26/34/42
191 // (2026-10-09), with sm's hit area at the floor, which
192 // TestEveryTapTargetIsAtLeast44Pixels measures; the drawn heights are
193 // pinned here and on touch in TestTheButtonSizesAreAnEvenScale.
194 if math.Round(d.Sm) != 26 || math.Round(d.Md) != 34 || math.Round(d.Lg) != 42 {
195 t.Errorf("desktop button heights %v/%v/%v, want 26/34/42", d.Sm, d.Md, d.Lg)
196 }
197 if d.Kebab != [2]float64{32, 32} {
198 t.Errorf("desktop kebab %v, want 32×32", d.Kebab)
199 }
200 if d.Box != [2]float64{16, 16} || d.Label != [2]float64{32, 32} {
201 t.Errorf("desktop row checkbox %v in a label %v, want the 16px box in a 32×32 target", d.Box, d.Label)
202 }
203 // A one-digit page chip was 53.19px wide before the floor reached the
204 // desktop (its 2rem minimum on the content box, with its padding and
205 // border on top). The floor's border-box made it 32; the floor
206 // raises the height to 32 and must not narrow it.
207 if d.Chip != [2]float64{53.19, 32} {
208 t.Errorf("desktop page chip %v, want 53.2×32: the old width, at the floor's height", d.Chip)
209 }
210 assertIcons(t, ctx, "1280 mouse")
211 // The floor is a minimum on the whole box, padding and border
212 // included. Measured on the content box it added the padding on top:
213 // a dropdown summary already 33.8px tall grew to 44.8, the topbar
214 // with it to 61.8, and the bulk bar's Actions to 41. These are exact,
215 // because a floor that only checks "at least 32" cannot see growth.
216 var rawFloor string
217 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => {
218 const h = sel => Math.round(document.querySelector(sel).getBoundingClientRect().height * 10) / 10;
219 const group = document.querySelector('[data-sample="dropdown"] [rst-menu-group] > summary');
220 for (let d = group.closest("details"); d; d = d.parentElement && d.parentElement.closest("details")) d.open = true;
221 const out = {Summary: h('[data-sample="dropdown"] [rst-dropdown] > summary'), Bulk: h('[data-partial="bulk-bar"] [rst-dropdown] > summary'),
222 Group: Math.round(group.getBoundingClientRect().height * 10) / 10, Help: h('[data-sample="help"] [rst-help]'),
223 Bar: h('[data-sample="shell-topbar"] [rst-shell-bar]'),
224 Search: h('[rst-search]:has([rst-search-clear])'), SearchInput: h('[rst-search]:has([rst-search-clear]) input[type="search"]'),
225 BulkSearch: h('#sizing-bulk [rst-search]')};
226 document.querySelectorAll("details[open]").forEach(d => { d.open = false; });
227 return JSON.stringify(out);
228 })()`, &rawFloor)); err != nil {
229 t.Fatal(err)
230 }
231 var f struct{ Summary, Bulk, Group, Help, Bar, ModalNav, Search, SearchInput, BulkSearch float64 }
232 if err := json.Unmarshal([]byte(rawFloor), &f); err != nil {
233 t.Fatal(err)
234 }
235 if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/modal"), chromedp.WaitReady("body"),
236 chromedp.Evaluate(`Math.round(document.querySelector("[rst-modal-panel] > nav a").getBoundingClientRect().height * 10) / 10`, &f.ModalNav)); err != nil {
237 t.Fatal(err)
238 }
239 for _, c := range []struct {
240 name string
241 got, want float64
242 }{
243 {"a dropdown summary (its own padding, over the floor)", f.Summary, 33.8},
244 {"the bulk bar's Actions summary", f.Bulk, 32},
245 {"a menu-group summary", f.Group, 32},
246 {"the help button, border included", f.Help, 32},
247 {"the topbar's bar", f.Bar, 50.8},
248 {"a modal nav link (its own padding, over the floor)", f.ModalNav, 33.8},
249 // Before the floor reached the desktop the search box was 35.6
250 // (a 33 without its clear link) around a 21.4px input, under the
251 // floor. Its input and clear link are the targets, and at 32 with
252 // the box's own 0.3rem block padding on top the box was 43.6 and
253 // every list bar grew with it. The padding goes and the box is
254 // the floor plus its border; the input fills it.
255 {"a search box with its clear link", f.Search, 34},
256 {"a search box's input", f.SearchInput, 32},
257 {"the bulk bar's search box", f.BulkSearch, 34},
258 } {
259 if c.got != c.want {
260 t.Errorf("desktop %s is %vpx tall, want %v", c.name, c.got, c.want)
261 }
262 }
263 c := openCalendar(t, ctx, rig.Origin+"/", 1280, 900)
264 if c.Position != "absolute" || c.InlineSize != "288px" {
265 t.Errorf("desktop calendar is %s, inline-size %s; want today's anchored 18rem (288px) panel", c.Position, c.InlineSize)
266 }
267 // The calendar is outside the target drive (it opens by script), so
268 // its controls are held to the desktop floor here.
269 if c.MinDayW < 31.5 || c.MinDayH < 31.5 || c.MinNavW < 31.5 || c.MinNavH < 31.5 {
270 t.Errorf("desktop calendar: smallest day %.1f×%.1f, month button %.1f×%.1f; want both at least 32×32", c.MinDayW, c.MinDayH, c.MinNavW, c.MinNavH)
271 }
272}
273
274// TestAnIconKeepsItsSpaceBesideItsText: a menu item (a link or a
275// button, in both spellings: a bulk action is a button), a nav link, the
276// brand, the back link, a row action and the sign-in link are flex boxes
277// on a phone, and the ones born inline are on a desktop too, so the
278// floor reaches them. A flex box drops the whitespace between an icon
279// and the word after it, and the icon touched the word (4px of space
280// became 0); each sets a gap, and a control that stays a block keeps its
281// space. This reads the space between the icon's edge and the word's,
282// with a mouse at 1280 and a coarse pointer at 390, in both directions
283// of text.
284func TestAnIconKeepsItsSpaceBesideItsText(t *testing.T) {
285 ids := []string{"icon-item", "icon-button", "icon-button-class", "icon-nav", "icon-brand", "icon-back", "icon-action", "icon-signin"}
286 for _, leg := range []struct {
287 name string
288 w, h int64
289 coarse bool
290 }{{"1280x900 mouse", 1280, 900, false}, {"390x844 touch", 390, 844, true}} {
291 rig := sizingRig(t, leg.coarse, map[string]string{"/": sizingDoc("sizing", sizingFixture(t))})
292 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
293 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")); err != nil {
294 cancel()
295 t.Fatal(err)
296 }
297 requirePointer(t, ctx, leg.coarse)
298 for _, dir := range []string{"ltr", "rtl"} {
299 var raw string
300 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`(() => {
301 document.documentElement.dir = %q;
302 document.querySelectorAll('[data-extra="icon-text"] details').forEach(d => { d.open = true; });
303 const out = {};
304 for (const id of %s) {
305 const a = document.getElementById(id);
306 if (!a.checkVisibility()) continue;
307 const i = a.querySelector("svg").getBoundingClientRect();
308 // From the word's first letter: in a block the space is
309 // the start of the text node, and is the gap itself.
310 const r = document.createRange(), w = a.lastChild; r.setStart(w, w.textContent.search(/\S/)); r.setEnd(w, w.textContent.length);
311 const tx = r.getBoundingClientRect();
312 out[id] = document.documentElement.dir === "rtl" ? i.left - tx.right : tx.left - i.right;
313 }
314 document.querySelectorAll('[data-extra="icon-text"] details').forEach(d => { d.open = false; });
315 return JSON.stringify(out);
316 })()`, dir, "[\""+strings.Join(ids, "\",\"")+"\"]"), &raw)); err != nil {
317 cancel()
318 t.Fatal(err)
319 }
320 var got map[string]float64
321 if err := json.Unmarshal([]byte(raw), &got); err != nil {
322 cancel()
323 t.Fatal(err)
324 }
325 // The rail is not drawn on a phone; every other control is.
326 if want := len(ids) - map[bool]int{true: 1, false: 0}[leg.coarse]; len(got) < want {
327 t.Errorf("%s, %s: measured %d of the icon-and-text controls, want %d: %v", leg.name, dir, len(got), want, got)
328 }
329 for id, gap := range got {
330 if gap < 3 {
331 t.Errorf("%s, %s: in #%s the icon is %.1fpx from its text; it touches the word", leg.name, dir, id, gap)
332 }
333 }
334 }
335 cancel()
336 }
337}
338
339// measureFn is the measuring half every target reading shares, written
340// once so the closed page, each opened overlay and the modal are the
341// same instrument. The activation area is the element a tap activates:
342// a checkbox or radio is measured as its label, a stretched link (an
343// absolutely positioned ::after with content) as its positioned
344// ancestor, the row, and a control that contains its own such
345// pseudo-element (an sm button, drawn under the floor) as its border box grown by that
346// hit area, with each edge of it hit-tested as well as the centre. Each area is scrolled into view (scrollIntoView
347// scrolls every scrolling ancestor, a rail or a menu panel included)
348// and measured again after the scroll, and the point tested is the
349// centre of the part inside the viewport. elementFromPoint there must
350// return the control or a descendant of it (a switch's track, a
351// summary's icon), or for a labelled input its label: anything else is
352// an occlusion failure, never a skip. Only a control that is not
353// rendered at all is skipped. An element marked
354// data-sizing-not-an-idiom is an app's own control on the fixture for
355// the font test and is not an idiom this floor covers.
356const measureFn = `function measure(root, skip) {
357 const CONTROLS = 'a[href], button, summary, input:not([type=hidden]), select, textarea, [rst-cal-day], [role="option"]';
358 const describe = el => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") +
359 [...el.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + (a.value ? "=" + a.value : "") + "]").join("") +
360 " “" + (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30) + "”";
361 const rendered = el => {
362 if (el.checkVisibility && !el.checkVisibility({visibilityProperty: true})) return false;
363 const r = el.getBoundingClientRect();
364 return r.width >= 2 && r.height >= 2 && getComputedStyle(el).clipPath !== "inset(50%)";
365 };
366 const area = el => {
367 if (el.matches("input[type=checkbox], input[type=radio]")) {
368 const l = el.closest("label") || (el.id && document.querySelector('label[for="' + el.id + '"]'));
369 if (l) return l;
370 }
371 // A link positioned itself contains its own ::after, so that is a
372 // hit area (an sm link-button's) rather than a stretch to the row.
373 if (el.tagName === "A" && getComputedStyle(el).position === "static") {
374 const after = getComputedStyle(el, "::after");
375 // pointer-events: none is a tooltip ([rst-tip]::after), which a tap
376 // passes through; only an overlay that takes the tap stretches the
377 // link, and mistaking the tip for one measured the whole page.
378 if (after.content !== "none" && after.position === "absolute" && after.pointerEvents !== "none" && el.offsetParent) return el.offsetParent;
379 }
380 return el;
381 };
382 // The box a tap reaches: the element's border box, grown by its own
383 // ::before or ::after where that is an absolutely positioned hit area
384 // the element contains (sm's, drawn under the floor). The ::after's insets are
385 // used values from the padding box, so the borders go back in.
386 const reach = el => {
387 const r = el.getBoundingClientRect(), s = getComputedStyle(el), px = v => parseFloat(v) || 0;
388 const u = {left: r.left, top: r.top, right: r.right, bottom: r.bottom};
389 for (const pseudo of ["::before", "::after"]) {
390 const a = getComputedStyle(el, pseudo);
391 if (s.position === "static" || a.content === "none" || a.position !== "absolute" || a.pointerEvents === "none") continue;
392 u.top = Math.min(u.top, r.top + px(s.borderTopWidth) + px(a.top)); u.bottom = Math.max(u.bottom, r.bottom - px(s.borderBottomWidth) - px(a.bottom));
393 u.left = Math.min(u.left, r.left + px(s.borderLeftWidth) + px(a.left)); u.right = Math.max(u.right, r.right - px(s.borderRightWidth) - px(a.right));
394 }
395 u.width = u.right - u.left; u.height = u.bottom - u.top;
396 return {box: u, grown: u.width > r.width + 0.5 || u.height > r.height + 0.5};
397 };
398 const out = [], seen = new Set();
399 root.querySelectorAll(CONTROLS).forEach(el => {
400 if (skip && skip(el)) return;
401 if (el.closest("[data-sizing-not-an-idiom]")) return;
402 // Inert content (a modal's backdrop) is unreachable on purpose.
403 if (el.closest("[inert]")) return;
404 const a = area(el);
405 if (seen.has(a) || !rendered(a)) return;
406 seen.add(a);
407 el.setAttribute("data-measured", "");
408 a.setAttribute("data-measured-area", "");
409 a.scrollIntoView({block: "center", inline: "center"});
410 const {box: r, grown} = reach(a);
411 const x0 = Math.max(r.left, 0), y0 = Math.max(r.top, 0), x1 = Math.min(r.right, innerWidth), y1 = Math.min(r.bottom, innerHeight);
412 // A grown area is only as big as the taps it really takes: each
413 // edge's middle, half a pixel in, must reach the control too, or a
414 // neighbour over the hit area (or a clipping ancestor) has it.
415 const points = [[(x0 + x1) / 2, (y0 + y1) / 2]];
416 if (grown) points.push([(x0 + x1) / 2, y0 + 0.5], [(x0 + x1) / 2, y1 - 0.5], [x0 + 0.5, (y0 + y1) / 2], [x1 - 0.5, (y0 + y1) / 2]);
417 let hit = null;
418 for (const [x, y] of points) {
419 hit = document.elementFromPoint(x, y);
420 if (!(hit === el || (hit && el.contains(hit)) || (a !== el && a.tagName === "LABEL" && a.contains(hit)))) break;
421 }
422 out.push({Name: describe(el), W: r.width, H: r.height,
423 Fits: r.left >= -0.5 && r.top >= -0.5 && r.right <= innerWidth + 0.5 && r.bottom <= innerHeight + 0.5,
424 // Ownership is the control, a descendant of it (a summary's icon,
425 // a switch's track), or, for a labelled input only, its label and
426 // what the label holds. Never "anything in the measured box": for a
427 // stretched link the box is the whole row, and a row holds other
428 // controls, so a hit on the kebab must not count for the name link.
429 Owns: !!hit && (hit === el || el.contains(hit) || (a !== el && a.tagName === "LABEL" && a.contains(hit))),
430 Hit: hit ? describe(hit) : "nothing",
431 Inline: el.tagName === "A" && !el.hasAttribute("rst-btn") && !!el.closest("p, [rst-field-help]") && !el.closest("nav")});
432 });
433 return out;
434}`
435
436// targetsJS measures the page as it loads: every overlay closed.
437const targetsJS = `(() => { ` + measureFn + `; return JSON.stringify(measure(document)); })()`
438
439// overlayJS opens the i-th <details> on the page (and every <details>
440// around it), measures what it revealed — its own summary was measured
441// closed — and closes everything again, so one open overlay never
442// counts as occluding the next. It is a function the caller applies to
443// i, not a Sprintf format: measureFn holds "inset(50%)", which a format
444// would mangle into a script error.
445const overlayJS = `(async (i) => { ` + measureFn + `;
446 const all = document.querySelectorAll("details");
447 if (i >= all.length) return JSON.stringify({Done: true});
448 const d = all[i];
449 for (let p = d; p; p = p.parentElement && p.parentElement.closest("details")) p.open = true;
450 // A menu inside the topbar's or console's tail is behind a SIBLING
451 // disclosure (the tail is the shell menu's next sibling, not its
452 // content), which the ancestor walk above cannot see.
453 const tail = d.closest("[rst-shell-tail], .rst-shell__tail");
454 if (tail && tail.previousElementSibling && tail.previousElementSibling.matches("details")) tail.previousElementSibling.open = true;
455 // A menu panel is position: fixed and anchored to its summary where
456 // anchor positioning is supported, and scrollIntoView on a fixed
457 // panel's items cannot scroll the page, so the anchor would stay
458 // wherever the last control left the page and the panel with it.
459 // Bring the outermost summary into view first, as a tap on it would,
460 // and wait two frames: an anchored panel takes up its anchor's new
461 // scroll position at the next frame, so a rect read at once is stale.
462 let outer = d;
463 while (outer.parentElement && outer.parentElement.closest("details")) outer = outer.parentElement.closest("details");
464 outer.querySelector(":scope > summary").scrollIntoView({block: "center", inline: "center"});
465 await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
466 const summary = d.querySelector(":scope > summary");
467 const got = measure(d, el => el === summary);
468 // A shell disclosure reveals its next SIBLING (the topbar's tail, the
469 // old sidebar's rail), not its own content.
470 if (d.matches("[rst-shell-menu], [rst-shell-chrome]") && d.nextElementSibling) got.push(...measure(d.nextElementSibling));
471 document.querySelectorAll("details[open]").forEach(x => { x.open = false; });
472 return JSON.stringify({Done: false, Targets: got});
473})`
474
475// assertTargets holds every measured control to 44×44, the floor on a
476// small or touch screen.
477func assertTargets(t *testing.T, where string, got []targetReading) {
478 t.Helper()
479 assertTargetsAt(t, where, got, 44)
480}
481
482// assertTargetsAt holds every measured control to floor×floor, whole in
483// the viewport and hittable at its centre. A link in running text is the
484// one exemption (WCAG 2.5.8's inline exception). The floor is 44 on a
485// small or touch screen and 32 on a desktop with a mouse.
486func assertTargetsAt(t *testing.T, where string, got []targetReading, floor float64) {
487 t.Helper()
488 for _, g := range got {
489 switch {
490 case !g.Fits:
491 t.Errorf("%s: %s is %.1f×%.1f and cannot be brought wholly into the viewport; too big to hit", where, g.Name, g.W, g.H)
492 case !g.Owns:
493 t.Errorf("%s: %s is occluded: a tap at its centre lands on %s", where, g.Name, g.Hit)
494 case g.Inline:
495 case g.W < floor-0.5 || g.H < floor-0.5:
496 t.Errorf("%s: %s is %.1f×%.1f, under the %v×%v floor", where, g.Name, g.W, g.H, floor, floor)
497 }
498 }
499}
500
501// measureEverything reads the closed page, then every overlay alone,
502// then the combobox's list and the date field's list, each opened by
503// the script that owns it, holding each control to floor.
504func measureEverything(t *testing.T, ctx context.Context, where string, floor float64) int {
505 t.Helper()
506 settleUntil(t, ctx, `!!document.querySelector("[rst-combo] [role=combobox]") && !!document.querySelector("[rst-dtp-pick]")`)
507 got := readTargets(t, ctx, targetsJS)
508 n := len(got)
509 assertTargetsAt(t, where+", overlays closed", got, floor)
510 for i := 0; ; i++ {
511 var raw string
512 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf("%s(%d)", overlayJS, i), &raw, awaitPromise)); err != nil {
513 t.Fatalf("%s: opening overlay %d: %v", where, i, err)
514 }
515 var o struct {
516 Done bool
517 Targets []targetReading
518 }
519 if err := json.Unmarshal([]byte(raw), &o); err != nil {
520 t.Fatal(err)
521 }
522 if o.Done {
523 break
524 }
525 n += len(o.Targets)
526 assertTargetsAt(t, fmt.Sprintf("%s, overlay %d open", where, i), o.Targets, floor)
527 }
528 for _, open := range []struct{ name, js, list string }{
529 {"the combobox's list", `(() => { const i = document.querySelector("#sizing-combo").closest("[rst-field]").querySelector("[role=combobox]"); i.focus(); i.click(); return true; })()`, "[rst-combo-list]"},
530 {"the date field's list", `(() => { const i = document.querySelector("[rst-dtp] [role=combobox]"); i.focus(); i.value = "t"; i.dispatchEvent(new Event("input", {bubbles: true})); return true; })()`, "[rst-dtp-list]"},
531 } {
532 if err := chromedp.Run(ctx, chromedp.Evaluate(open.js, nil)); err != nil {
533 t.Fatalf("%s: opening %s: %v", where, open.name, err)
534 }
535 settleUntil(t, ctx, `(() => { const l = document.querySelector("`+open.list+`"); return !!l && l.checkVisibility() && l.querySelectorAll("[role=option]").length > 0; })()`)
536 got := readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify(measure(document.querySelector("`+open.list+`"))); })()`)
537 if len(got) == 0 {
538 t.Fatalf("%s: %s opened with no options to measure", where, open.name)
539 }
540 n += len(got)
541 assertTargetsAt(t, where+", "+open.name, got, floor)
542 chromedp.Run(ctx, chromedp.KeyEvent(kb.Escape))
543 }
544 return n
545}
546
547// iconsJS reads every rendered icon inside a control on the page as
548// it loads: fiesta.Icon's svg.icon, sized by the control and not by
549// the text around it, and whether it sits in a caret.
550const iconsJS = `JSON.stringify([...document.querySelectorAll(":is(a[href], button, summary, label, [role=button]) svg.icon")]
551 .filter(i => i.checkVisibility({visibilityProperty: true}))
552 .map(i => { const r = i.getBoundingClientRect(), c = i.closest("a[href], button, summary, label, [role=button]");
553 return {Name: c.tagName.toLowerCase() + [...c.attributes].filter(a => a.name.startsWith("rst-")).map(a => "[" + a.name + "]").join("") + " “" + (c.getAttribute("aria-label") || c.textContent).trim().slice(0, 30) + "”",
554 Caret: !!i.closest("[rst-caret], .rst-caret"), W: Math.round(r.width * 100) / 100, H: Math.round(r.height * 100) / 100}; }))`
555
556// iconReading is one icon inside a control, as iconsJS reads it.
557type iconReading struct {
558 Name string
559 Caret bool
560 W, H float64
561}
562
563// readIcons reads every icon inside a control on the page.
564func readIcons(t *testing.T, ctx context.Context, where string) []iconReading {
565 t.Helper()
566 var raw string
567 if err := chromedp.Run(ctx, chromedp.Evaluate(iconsJS, &raw)); err != nil {
568 t.Fatalf("%s: reading icons: %v", where, err)
569 }
570 var got []iconReading
571 if err := json.Unmarshal([]byte(raw), &got); err != nil {
572 t.Fatal(err)
573 }
574 return got
575}
576
577// assertIconSizes holds every icon read to 16×16 and a caret's chevron
578// to 12×12, and returns how many of each kind it read.
579func assertIconSizes(t *testing.T, where string, got []iconReading) (carets, icons int) {
580 t.Helper()
581 for _, g := range got {
582 want := 16.0
583 if g.Caret {
584 want, carets = 12, carets+1
585 } else {
586 icons++
587 }
588 if g.W != want || g.H != want {
589 t.Errorf("%s: the icon in %s is %v×%v, want %v×%v", where, g.Name, g.W, g.H, want, want)
590 }
591 }
592 return carets, icons
593}
594
595// assertIcons holds every icon inside a control to 16×16 and a caret's
596// chevron to 12×12. An icon sized from its font came out 9 to 20px
597// depending on the text beside it (a caret in an xs summary, a menu
598// icon in a 1.25rem button), so two icons in one toolbar disagreed. At
599// least one of each kind must be read, or a fixture that lost its
600// carets would pass with nothing measured.
601func assertIcons(t *testing.T, ctx context.Context, where string) {
602 t.Helper()
603 carets, icons := assertIconSizes(t, where, readIcons(t, ctx, where))
604 if carets == 0 || icons == 0 {
605 t.Errorf("%s: read %d caret icons and %d other icons in controls; the fixture must show at least one of each", where, carets, icons)
606 }
607}
608
609// TestAnIconOutsideAControlFollowsItsText pins where the fixed icon
610// size stops. Inside a control an icon is 16px whatever the text beside
611// it (assertIcons); everywhere else it is 1em and follows its text, as
612// it always did: an icon in a 24px heading is 24px, one in a line of
613// prose or in a link in that line is the line's size. A 1rem .icon
614// made every one of them 16px, shrinking heading icons and growing
615// small print's. The button beside them is the control: 16px.
616func TestAnIconOutsideAControlFollowsItsText(t *testing.T) {
617 page := map[string]string{"/": sizingDoc("icons", `<div rst-page>`+
618 `<h2 id="heading" style="font-size: 24px">`+sizingIcon+` Orders</h2>`+
619 `<p id="prose" style="font-size: 12px">`+sizingIcon+` Shipped. <a href="#guide" id="prose-link">`+sizingIcon+` Read the guide</a></p>`+
620 `<button rst-btn type="button" id="button">`+sizingIcon+` Save</button></div>`)}
621 for _, leg := range []struct {
622 name string
623 w, h int64
624 coarse bool
625 }{{"1280x900 mouse", 1280, 900, false}, {"390x844 touch", 390, 844, true}} {
626 rig := sizingRig(t, leg.coarse, page)
627 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
628 var got map[string]float64
629 var raw string
630 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("#button", chromedp.ByQuery),
631 chromedp.Evaluate(`JSON.stringify(Object.fromEntries(["heading", "prose", "prose-link", "button"].map(id => [id, document.querySelector("#" + id + " > svg").getBoundingClientRect().height])))`, &raw)); err != nil {
632 cancel()
633 t.Fatal(err)
634 }
635 cancel()
636 if err := json.Unmarshal([]byte(raw), &got); err != nil {
637 t.Fatal(err)
638 }
639 for id, want := range map[string]float64{"heading": 24, "prose": 12, "prose-link": 12, "button": 16} {
640 if got[id] != want {
641 t.Errorf("%s: the icon in #%s is %vpx, want %v", leg.name, id, got[id], want)
642 }
643 }
644 }
645}
646
647// assertCovered is what makes "every control passed" mean something,
648// in two halves. Every rendered control on the closed page was measured
649// (none skipped by a bug in measure itself), and for every inventory row
650// on this page at least one element matching its Probe was measured
651// (measureFn marks each with data-measured), so an idiom cannot drop out
652// of the drive because its only fixture went away. Each named fixture
653// control is also required by id in TestTextControlsAreSixteenPixels.
654func assertCovered(t *testing.T, ctx context.Context, where, page string, wide bool) {
655 t.Helper()
656 var missed []string
657 if err := chromedp.Run(ctx, chromedp.Evaluate(`[...document.querySelectorAll('a[href], button, summary, input:not([type=hidden]), select, textarea')]
658 .filter(e => !e.hasAttribute("data-measured") && !e.closest("[data-sizing-not-an-idiom], [inert]") && e.checkVisibility({visibilityProperty: true}) && getComputedStyle(e).clipPath !== "inset(50%)" && e.getBoundingClientRect().width >= 2)
659 .filter(e => !(e.matches("input[type=checkbox], input[type=radio]") && e.closest("label") && e.closest("label").hasAttribute("data-measured-area")))
660 .map(e => e.outerHTML.slice(0, 80))`, &missed)); err != nil {
661 t.Fatalf("%s: listing unmeasured controls: %v", where, err)
662 }
663 if len(missed) > 0 {
664 t.Errorf("%s: %d rendered controls were never measured: %v", where, len(missed), missed)
665 }
666 for _, e := range tapInventory {
667 sel, entryPage := e.Probe, ""
668 switch {
669 case strings.HasPrefix(sel, "elsewhere:"):
670 continue
671 case strings.HasPrefix(sel, "narrow:"):
672 if wide {
673 continue
674 }
675 sel = strings.TrimPrefix(sel, "narrow:")
676 case strings.HasPrefix(sel, "modal:"):
677 sel, entryPage = strings.TrimPrefix(sel, "modal:"), "modal"
678 }
679 if entryPage != page {
680 continue
681 }
682 var n int
683 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`[...document.querySelectorAll(%q)].filter(e => e.hasAttribute("data-measured")).length`, sel), &n)); err != nil {
684 t.Fatalf("%s: counting %s: %v", where, sel, err)
685 }
686 if n == 0 {
687 t.Errorf("%s: no %s (%s) was measured; the idiom dropped out of the drive", where, sel, e.Idiom)
688 }
689 }
690}
691
692// fixtureCounts are controls the sizing fixture itself owns, with how
693// many of each must have been measured. Declared here, not read off the
694// page: a control that disappears from the fixture is absent from the
695// DOM too, so only a number written down independently can notice it,
696// even while another control of the same idiom survives.
697var fixtureCounts = []struct {
698 Sel string
699 N int
700}{
701 {`[data-extra="buttons"] [rst-btn]`, 3},
702 {`[data-extra="locale"] [rst-locale] button`, 3},
703 {`[data-extra="combobox"] [role=option]`, 12},
704 {`[data-extra="legacy-drawer"] [rst-shell-nav] a`, 2},
705 {`[data-extra="small-parents"] :is(input, textarea)`, 7}, // the bare input is the app's own, not measured
706 // Previous and 1 are spans (disabled, current); 9 and Next are links
707 // at every width. 2, beside the current page, is a link only a wide
708 // strip shows, so it is measured where it is drawn and not counted.
709 {`[data-partial="pagination"] [rst-pagination] a:not([rst-pagination-wide])`, 2},
710 {`[data-partial="bulk-bar"] [rst-dropdown-menu] button`, 2},
711 {`[data-partial="seg-tabs"] a`, 2},
712 {`[data-partial="seg-tabs"] [rst-seg-tabs] > button`, 2},
713 {`[data-partial="side-nav"] [rst-side-nav] a`, 2},
714 {`[data-partial="dropdown"] [rst-dropdown-menu] a`, 1},
715 {`[data-extra="short-labels"] [rst-bulkbar-escalate]`, 1},
716 {`[data-extra="short-labels"] a[rst-person]`, 1},
717 {`[data-extra="signin-providers"] [rst-signin-providers] > a`, 1},
718}
719
720func assertFixtureCounts(t *testing.T, ctx context.Context, where string) {
721 t.Helper()
722 for _, c := range fixtureCounts {
723 var n int
724 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`[...document.querySelectorAll(%q)].filter(e => e.hasAttribute("data-measured")).length`, c.Sel), &n)); err != nil {
725 t.Fatalf("%s: counting %s: %v", where, c.Sel, err)
726 }
727 if n != c.N {
728 t.Errorf("%s: %d of %s were measured, want exactly %d", where, n, c.Sel, c.N)
729 }
730 }
731}
732
733// TestEveryTapTargetIsAtLeast44Pixels is §10.1's target half, at 390
734// and 1024 with a coarse pointer and at 600 with a mouse, then the modal
735// on a page of its own. The 1280 mouse leg is the desktop floor: the
736// same drive, every control at least 32×32, so a control that is only
737// floored on touch (a 26px kebab, a 24px clear link) fails there. Every
738// leg also holds the icons inside controls to 16px and a caret's to 12.
739func TestEveryTapTargetIsAtLeast44Pixels(t *testing.T) {
740 pages := map[string]string{
741 "/": sizingDoc("sizing", sizingFixture(t)),
742 "/modal": sizingDoc("modal", modalSample(t, "TestEveryTapTargetIsAtLeast44Pixels")),
743 }
744 for _, leg := range []struct {
745 name string
746 w, h int64
747 coarse bool
748 floor float64
749 }{
750 {"390x844 touch", 390, 844, true, 44},
751 {"1024x768 touch", 1024, 768, true, 44},
752 {"600x800 mouse", 600, 800, false, 44},
753 {"1280x900 mouse", 1280, 900, false, 32},
754 } {
755 t.Run(leg.name, func(t *testing.T) {
756 rig := sizingRig(t, leg.coarse, pages)
757 ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second)
758 defer cancel()
759 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+"/")); err != nil {
760 t.Fatal(err)
761 }
762 requirePointer(t, ctx, leg.coarse)
763 assertIcons(t, ctx, leg.name)
764 n := measureEverything(t, ctx, leg.name, leg.floor)
765 t.Logf("%s: %d controls measured", leg.name, n)
766 assertCovered(t, ctx, leg.name, "", leg.w >= 800)
767 assertFixtureCounts(t, ctx, leg.name)
768 if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/modal")); err != nil {
769 t.Fatal(err)
770 }
771 got := readTargets(t, ctx, targetsJS)
772 assertTargetsAt(t, leg.name+", the modal", got, leg.floor)
773 assertCovered(t, ctx, leg.name+", the modal", "modal", leg.w >= 800)
774 })
775 }
776}
777
778// TestTheKebabOverflowsIntoTheGapOnALiteralColumn is §2.3's claim for
779// an app that keeps --rst-cols' literal 32px: on a wide touch screen the
780// 44px kebab is justify-self: end, so it spills 12px toward the inline
781// start, into the 0.85rem gap, and never over the cell beside it.
782func TestTheKebabOverflowsIntoTheGapOnALiteralColumn(t *testing.T) {
783 page := sizingDoc("literal", `<div rst-page><div rst-card style="--rst-cols: 1fr 110px 32px"><div rst-lrow>`+
784 `<a class="rst-nm" href="#">Grace Hopper</a><span id="cell" class="rst-cell-mut">Paid</span>`+
785 `<details rst-row-menu name="rst-menus"><summary id="kebab" aria-label="Actions">⋮</summary><div rst-row-menu-panel><a href="#">View</a></div></details>`+
786 `</div></div></div>`)
787 rig := sizingRig(t, true, map[string]string{"/": page})
788 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
789 defer cancel()
790 var raw string
791 if err := chromedp.Run(ctx, chromedp.EmulateViewport(1024, 768), chromedp.Navigate(rig.Origin+"/"),
792 chromedp.Evaluate(`(() => { const c = document.getElementById("cell").getBoundingClientRect(), k = document.getElementById("kebab").getBoundingClientRect();
793 return JSON.stringify({CellEnd: c.right, KebabStart: k.left, W: k.width}); })()`, &raw)); err != nil {
794 t.Fatal(err)
795 }
796 requirePointer(t, ctx, true)
797 var g struct{ CellEnd, KebabStart, W float64 }
798 json.Unmarshal([]byte(raw), &g)
799 if g.W < 43.5 {
800 t.Fatalf("the kebab is %.1fpx wide on a touch screen; the premise (a 44px kebab in a 32px track) is not met", g.W)
801 }
802 if g.KebabStart < g.CellEnd-0.5 {
803 t.Errorf("the 44px kebab starts at %.1f and the cell beside it ends at %.1f: it overlaps the neighbouring cell", g.KebabStart, g.CellEnd)
804 }
805}
806
807// calJS reads the open calendar: where the panel is, whether it
808// scrolls, how big each day is, and whether every day and both month
809// buttons can be scrolled to and hit.
810const calJS = `(() => {
811 const cal = document.querySelector("[rst-cal]");
812 if (!cal || !cal.checkVisibility()) return JSON.stringify({Open: false});
813 const items = [...cal.querySelectorAll("[rst-cal-nav], [rst-cal-day]")];
814 let minW = 1e9, minH = 1e9, navW = 1e9, navH = 1e9; const unhittable = [];
815 for (const el of items) {
816 el.scrollIntoView({block: "nearest", inline: "nearest"});
817 const b = el.getBoundingClientRect();
818 if (el.matches("[rst-cal-day]")) { minW = Math.min(minW, b.width); minH = Math.min(minH, b.height); }
819 else { navW = Math.min(navW, b.width); navH = Math.min(navH, b.height); }
820 const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
821 if (!hit || !(hit === el || el.contains(hit))) unhittable.push((el.getAttribute("data-rst-day") || "nav") + " -> " + (hit ? hit.tagName : "nothing"));
822 }
823 cal.scrollTop = 0;
824 const r = cal.getBoundingClientRect(), g = cal.querySelector("[rst-cal-grid]").getBoundingClientRect(), cs = getComputedStyle(cal);
825 return JSON.stringify({Open: true, Position: cs.position, InlineSize: cs.inlineSize, Left: r.left, Top: r.top, Right: r.right, Bottom: r.bottom, H: r.height,
826 VW: document.documentElement.clientWidth, VH: innerHeight, GridW: g.width, ScrollH: cal.scrollHeight, ClientH: cal.clientHeight,
827 Gutter: cal.offsetWidth - cal.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth),
828 Days: cal.querySelectorAll("[rst-cal-day]").length, Navs: cal.querySelectorAll("[rst-cal-nav]").length,
829 MinDayW: minW, MinDayH: minH, MinNavW: navW, MinNavH: navH, Unhittable: unhittable});
830})()`
831
832type calReading struct {
833 Open bool
834 Position, InlineSize string
835 Left, Top, Right, Bottom, H, VW, VH float64
836 GridW, ScrollH, ClientH, MinDayW, MinDayH float64
837 MinNavW, MinNavH, Gutter float64
838 Days, Navs int
839 Unhittable []string
840}
841
842// openCalendar loads url at w×h, presses the first date field's
843// calendar button (through the page, which is what a tap does) and
844// reads the panel.
845func openCalendar(t *testing.T, ctx context.Context, url string, w, h int64) calReading {
846 t.Helper()
847 if err := chromedp.Run(ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(url)); err != nil {
848 t.Fatal(err)
849 }
850 settleUntil(t, ctx, `!!document.querySelector("[rst-dtp-pick]")`)
851 if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector("[rst-dtp-pick]").click(), true`, nil)); err != nil {
852 t.Fatal(err)
853 }
854 settleUntil(t, ctx, `document.querySelectorAll("[rst-cal] [rst-cal-day]").length === 42`)
855 var raw string
856 if err := chromedp.Run(ctx, chromedp.Evaluate(calJS, &raw)); err != nil {
857 t.Fatal(err)
858 }
859 var c calReading
860 if err := json.Unmarshal([]byte(raw), &c); err != nil {
861 t.Fatalf("reading the calendar (%q): %v", raw, err)
862 }
863 if !c.Open || c.Days != 42 || c.Navs != 2 {
864 t.Fatalf("the calendar is open=%v with %d days and %d month buttons, want six weeks and two buttons", c.Open, c.Days, c.Navs)
865 }
866 return c
867}
868
869func inViewport(c calReading) bool {
870 return c.Left >= -0.5 && c.Top >= -0.5 && c.Right <= c.VW+0.5 && c.Bottom <= c.VH+0.5
871}
872
873// TestTheCalendarDocksAndItsDaysAreTaps is §10.1's calendar: docked
874// inside the viewport with 44×44 days at 390; no taller than a
875// landscape phone and scrolling itself at 640×320, with every day and
876// both month buttons reachable; with classic scrollbars at 600×320 and
877// 390×320, mouse and touch, still scrolling, days still 44 wide, the
878// grid exactly seven taps (the gutter widens the panel, never narrows a
879// day); at 320 the deliberate exception (days ≥24 wide, 44 tall, no
880// overflow); and with the field at the inline end of a field row, still
881// inside the viewport.
882func TestTheCalendarDocksAndItsDaysAreTaps(t *testing.T) {
883 field := fixture(t, "sizing_browser_test.go", "TestTheCalendarDocksAndItsDaysAreTaps", "field")
884 pages := map[string]string{
885 "/": sizingDoc("calendar", `<div rst-page>`+field+`</div>`),
886 "/end": sizingDoc("calendar at the end of a row", `<div rst-page><div rst-field-row><div rst-field class="rst-grow"><label rst-field-label for="n">Name</label><input rst-input id="n" name="n"></div><div>`+field+`</div></div></div>`),
887 }
888 touch := sizingRig(t, true, pages)
889 ctx, cancel := context.WithTimeout(touch.Context(), 180*time.Second)
890 defer cancel()
891
892 c := openCalendar(t, ctx, touch.Origin+"/", 390, 844)
893 requirePointer(t, ctx, true)
894 if c.Position != "fixed" || !inViewport(c) {
895 t.Errorf("390 touch: the calendar is %s at %.0f..%.0f × %.0f..%.0f in a %.0f×%.0f viewport; it is not docked inside it", c.Position, c.Left, c.Right, c.Top, c.Bottom, c.VW, c.VH)
896 }
897 if c.MinDayW < 43.5 || c.MinDayH < 43.5 {
898 t.Errorf("390 touch: the smallest day is %.1f×%.1f, want 44×44", c.MinDayW, c.MinDayH)
899 }
900 if math.Abs(c.GridW-308) > 0.5 {
901 t.Errorf("390 touch: the grid is %.1fpx, want seven taps (308)", c.GridW)
902 }
903
904 // The pointer half of the query alone: a tablet wider than 40rem
905 // docks too (spec §1.4), so a docking rule scoped to width only
906 // would leave it the anchored 18rem panel with 41px days.
907 c = openCalendar(t, ctx, touch.Origin+"/", 1024, 768)
908 if c.Position != "fixed" || !inViewport(c) {
909 t.Errorf("1024 touch: the calendar is %s at %.0f..%.0f × %.0f..%.0f in a %.0f×%.0f viewport; it is not docked inside it", c.Position, c.Left, c.Right, c.Top, c.Bottom, c.VW, c.VH)
910 }
911 if c.MinDayW < 43.5 || c.MinDayH < 43.5 {
912 t.Errorf("1024 touch: the smallest day is %.1f×%.1f, want 44×44", c.MinDayW, c.MinDayH)
913 }
914 if math.Abs(c.GridW-308) > 0.5 {
915 t.Errorf("1024 touch: the grid is %.1fpx, want seven taps (308)", c.GridW)
916 }
917
918 c = openCalendar(t, ctx, touch.Origin+"/", 640, 320)
919 if c.H > c.VH+0.5 || c.ScrollH <= c.ClientH || len(c.Unhittable) > 0 {
920 t.Errorf("640x320 touch: panel %.0fpx in a %.0fpx viewport, scrolls=%v, unreachable %v", c.H, c.VH, c.ScrollH > c.ClientH, c.Unhittable)
921 }
922
923 c = openCalendar(t, ctx, touch.Origin+"/", 320, 640)
924 // 40, not 24: 24 is WCAG 2.5.8's floor, but the deliberate
925 // exception is the geometry of a clamped panel, about 41px
926 // days, and a regression to anything narrower is a change to it.
927 // That is a phone's geometry, whose overlay scrollbar takes no
928 // width. Headless Chromium hides scrollbars without making them
929 // overlay ones, so scrollbar-gutter: stable still reserves a classic
930 // 15px here and takes a seventh of it from each day (38.7px); the
931 // gutter is measured and given back, so the floor is the phone's.
932 if phone := c.MinDayW + c.Gutter/7; phone < 40 || c.MinDayH < 43.5 || c.Right > c.VW+0.5 || c.Left < -0.5 {
933 t.Errorf("320 touch: days %.1f×%.1f (%.1f wide without the %.0fpx gutter), panel %.0f..%.0f in %.0f; the approved exception is about 41 wide, 44 tall, no overflow", c.MinDayW, c.MinDayH, phone, c.Gutter, c.Left, c.Right, c.VW)
934 }
935
936 c = openCalendar(t, ctx, touch.Origin+"/end", 390, 844)
937 if !inViewport(c) {
938 t.Errorf("390 touch, field at the row's end: the calendar is at %.0f..%.0f in %.0f", c.Left, c.Right, c.VW)
939 }
940
941 // Classic scrollbars, which the harness hides by default: the case
942 // where the gutter once came out of the days.
943 for _, coarse := range []bool{false, true} {
944 rig := sizingRig(t, coarse, pages, harness.WithScrollbars())
945 sctx, scancel := context.WithTimeout(rig.Context(), 90*time.Second)
946 // The days alone cannot tell whether the gutter is reserved:
947 // Chromium already counts a scrollbar it draws in a fit-content
948 // box's width, so they stay 44 either way. What the reservation
949 // adds is a panel that keeps one width whether or not it scrolls,
950 // so a month that fits a tall viewport is measured as the control
951 // and every short viewport must match it.
952 tall := openCalendar(t, sctx, rig.Origin+"/", 390, 844)
953 if tall.ScrollH > tall.ClientH {
954 t.Errorf("390x844 classic scrollbars, coarse=%v: the panel scrolls (%.0f > %.0f), so it cannot be the width of a panel that does not", coarse, tall.ScrollH, tall.ClientH)
955 }
956 for _, vp := range [][2]int64{{600, 320}, {390, 320}} {
957 c := openCalendar(t, sctx, rig.Origin+"/", vp[0], vp[1])
958 requirePointer(t, sctx, coarse)
959 where := fmt.Sprintf("%dx%d classic scrollbars, coarse=%v", vp[0], vp[1], coarse)
960 if w, tw := c.Right-c.Left, tall.Right-tall.Left; math.Abs(w-tw) > 0.5 {
961 t.Errorf("%s: the scrolling panel is %.1fpx and the same month at 390x844 is %.1fpx; the gutter is not reserved, so the panel jumps when the viewport gets short", where, w, tw)
962 }
963 if c.ScrollH <= c.ClientH {
964 t.Errorf("%s: the panel is not scrolling (%.0f ≤ %.0f); the gutter case this leg is for has not arisen", where, c.ScrollH, c.ClientH)
965 }
966 if c.MinDayW < 43.5 {
967 t.Errorf("%s: a day is %.1fpx wide; the scrollbar's gutter came out of the days", where, c.MinDayW)
968 }
969 if c.MinDayH < 43.5 {
970 t.Errorf("%s: a day is %.1fpx tall, want 44", where, c.MinDayH)
971 }
972 if math.Abs(c.GridW-308) > 0.5 {
973 t.Errorf("%s: the grid is %.1fpx, want exactly seven taps (308)", where, c.GridW)
974 }
975 if !inViewport(c) {
976 t.Errorf("%s: the panel is at %.0f..%.0f × %.0f..%.0f, outside the viewport", where, c.Left, c.Right, c.Top, c.Bottom)
977 }
978 }
979 scancel()
980 }
981}
982
983// floorOnly are the layout declarations the floor block may carry
984// besides the floor, each because the floor does not work without it,
985// keyed by the attribute spelling of the control that carries them.
986// An <a> is inline, and min sizes do nothing on an inline box, so the
987// controls born inline become inline-flex, centred in the box the floor
988// grows (or, blockified by a flex or grid parent, flex). The search
989// box's input is its target, so the box gives up its block padding and
990// the input stretches to the floor; the bulk bar's close button would
991// shrink below the floor in its flex row; the row checkbox centres its
992// 16px box in the label the floor widens.
993var floorOnly = map[string]string{
994 "[rst-row-action]": "display align-items justify-content",
995 "[rst-bulkbar-escalate]": "display align-items justify-content",
996 "[rst-modal-close]": "display align-items justify-content",
997 "[rst-back-nav] a": "display align-items",
998 "[rst-shell-brand]": "display align-items",
999 "[rst-search]": "padding-top padding-bottom",
1000 "[rst-search] input": "align-self",
1001 "[rst-bulkbar-close]": "flex-shrink",
1002 "[rst-selbox]": "justify-content",
1003 "[rst-signin-providers] > a": "display align-items align-self",
1004}
1005
1006// mayShrink are the controls the floor makes smaller on a desktop, by
1007// selector as the floor block spells it, each with why.
1008var mayShrink = map[string]string{
1009 "[rst-search]": "the box gives up its block padding so its input, the target, can be the floor: 35.6px became 34",
1010 `[rst-search] input[type="search"]`: "it stretches to the box's height, and its width loses what the box's border-box floor takes",
1011}
1012
1013// TestTheFloorChangesNothingButSizeOnADesktop: the floor block reaches
1014// every width, and the layout it carried from its touch-only days came
1015// with it: a row action 9.6px wider than the floor needed, a date
1016// suggestion's label moved off its baseline, a full-width button's
1017// label pulled to its middle, menu items, tabs and nav links made flex
1018// boxes that drop the spaces in their labels. Every control the block
1019// names is read at 1280 with a mouse, once as served and once with the
1020// block cut out; the two may differ in size, box-sizing and
1021// align-content (which centres a block's content in the floor), and in
1022// floorOnly's declarations, and in nothing else. And the size may only
1023// grow: a floor once replaced the textarea's own 5rem minimum (one
1024// property under two names), and a note field shrank to two lines.
1025func TestTheFloorChangesNothingButSizeOnADesktop(t *testing.T) {
1026 without, block := floorBlock(t)
1027 var sels []string
1028 for _, r := range leafRules(t, block) {
1029 for _, s := range splitSelectorList(r.selector) {
1030 if !strings.HasPrefix(s, ":where(") {
1031 sels = append(sels, s)
1032 }
1033 }
1034 }
1035 body := sizingFixture(t) + `<div rst-page data-extra="floor-layout"><div rst-dtp-row id="dtp-row"><span>Tomorrow</span><span>Thu 9 Oct</span></div>` +
1036 `<p><a rst-btn href="#wide" style="display: flex">Continue</a></p></div>`
1037 pages := map[string]string{"/": sizingDoc("floor", body), "/bare": strings.Replace(sizingDoc("floor", body), "/tokens.css", "/tokens-nofloor.css", 1)}
1038 rig := harness.New(t, func(string) http.Handler {
1039 mux := sizingMux(t, pages)
1040 mux.HandleFunc("GET /tokens-nofloor.css", func(w http.ResponseWriter, _ *http.Request) {
1041 w.Header().Set("Content-Type", "text/css")
1042 fmt.Fprint(w, without)
1043 })
1044 return mux
1045 })
1046 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
1047 defer cancel()
1048 read := `(() => {
1049 const sels = ` + mustJSON(t, sels) + `, props = ` + mustJSON(t, strings.Fields("display align-items justify-content padding-top padding-right padding-bottom padding-left align-self flex-shrink flex-grow row-gap column-gap text-align vertical-align")) + `;
1050 const seen = new Map();
1051 for (const sel of sels) for (const e of document.querySelectorAll(sel)) if (!seen.has(e)) seen.set(e, sel);
1052 const all = [...document.querySelectorAll("*")];
1053 return JSON.stringify([...seen].map(([e, sel]) => {
1054 const cs = getComputedStyle(e), out = {I: all.indexOf(e), Sel: sel, Name: e.tagName.toLowerCase() + (e.id ? "#" + e.id : "") + " “" + (e.getAttribute("aria-label") || e.textContent).trim().slice(0, 24) + "”", P: {}};
1055 for (const p of props) out.P[p] = cs.getPropertyValue(p);
1056 const box = e.getBoundingClientRect(); out.W = box.width; out.H = box.height;
1057 return out;
1058 }));
1059 })()`
1060 type styled struct {
1061 I int
1062 Sel, Name string
1063 P map[string]string
1064 W, H float64
1065 }
1066 at := func(path string) map[int]styled {
1067 var raw string
1068 if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("#dtp-row", chromedp.ByQuery), chromedp.Evaluate(read, &raw)); err != nil {
1069 t.Fatalf("%s: %v", path, err)
1070 }
1071 var got []styled
1072 if err := json.Unmarshal([]byte(raw), &got); err != nil {
1073 t.Fatal(err)
1074 }
1075 out := map[int]styled{}
1076 for _, g := range got {
1077 out[g.I] = g
1078 }
1079 return out
1080 }
1081 served, bare := at("/"), at("/bare")
1082 if len(served) < 50 {
1083 t.Fatalf("read %d controls the floor block names; the selectors no longer match the fixture", len(served))
1084 }
1085 for i, s := range served {
1086 b, ok := bare[i]
1087 if !ok {
1088 t.Errorf("%s: not on the page without the floor block", s.Name)
1089 continue
1090 }
1091 if _, ok := mayShrink[s.Sel]; !ok && (s.W < b.W-1 || s.H < b.H-1) {
1092 t.Errorf("%s (%s): the floor shrinks it on a desktop from %.1f×%.1f to %.1f×%.1f; a floor only raises", s.Name, s.Sel, b.W, b.H, s.W, s.H)
1093 }
1094 may := ""
1095 for key, props := range floorOnly {
1096 if strings.Contains(s.Sel, key) {
1097 may += " " + props
1098 }
1099 }
1100 // A gap does nothing outside a flex or grid box; the one that
1101 // puts back an icon's space applies where the floor made one,
1102 // which is floorOnly's to allow.
1103 if d := s.P["display"]; !strings.Contains(d, "flex") && !strings.Contains(d, "grid") || !strings.Contains(b.P["display"], "flex") {
1104 may += " column-gap row-gap"
1105 }
1106 for p, v := range s.P {
1107 if v != b.P[p] && !strings.Contains(" "+may+" ", " "+p+" ") {
1108 t.Errorf("%s (%s): the floor changes %s on a desktop from %q to %q", s.Name, s.Sel, p, b.P[p], v)
1109 }
1110 }
1111 }
1112}
1113
1114func mustJSON(t *testing.T, v any) string {
1115 t.Helper()
1116 b, err := json.Marshal(v)
1117 if err != nil {
1118 t.Fatal(err)
1119 }
1120 return string(b)
1121}
1122