| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "regexp" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "amadan.net/rastrillo/fiesta" |
| 11 | "amadan.net/rastrillo/fiesta/example" |
| 12 | ) |
| 13 | |
| 14 | // The stylesheet half of the touch-size gates: the zoom floors, the |
| 15 | // type step, the target floor and the tap inventory, read off |
| 16 | // tokens.css with the declaration reader. The browser half, which |
| 17 | // measures what the floors produce, reads the same inventory from the |
| 18 | // drives. |
| 19 | |
| 20 | // touchContext is the one media query every touch-only rule sits in |
| 21 | // (§1.1 of the mobile ergonomics design spec), as a rule's context |
| 22 | // names it. Written once so a typo in one floor cannot quietly scope |
| 23 | // it to a query nothing matches. |
| 24 | const touchContext = "@media (pointer: coarse), (max-width: 40rem)" |
| 25 | |
| 26 | // bareZoomFloor is the zero-weight floor for an app's own inputs: an |
| 27 | // input with no rst- attribute is sized by the UA stylesheet, which any |
| 28 | // author rule beats, so :where() still lifts it and any app rule that |
| 29 | // sizes it still wins. |
| 30 | const bareZoomFloor = ":where(input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea)" |
| 31 | |
| 32 | // TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin is §1.3's position |
| 33 | // gate. Each component floor must come DIRECTLY after the rule that |
| 34 | // resets its font (`font: inherit` resets font-size, so a floor before |
| 35 | // it is switched off) and, for the input, before the rule that makes |
| 36 | // the primary field big (at equal weight the later rule wins, and |
| 37 | // max(1rem, 1em) would pull the 19px field down to 16px — the 1em trap |
| 38 | // this rewrite exists to remove). Moving any floor one rule up or down |
| 39 | // fails here. |
| 40 | func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| 41 | // Read as rules, each knowing the top-level statement it sits in: |
| 42 | // a floor is a touch query holding exactly one rule, whose only |
| 43 | // declaration is font-size: max(1rem, 1em), and "directly after" |
| 44 | // is the next statement. Read as text, the rule a floor follows |
| 45 | // ended at the first }, a quoted one included. |
| 46 | css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| 47 | rules := leafRules(t, css) |
| 48 | byTop := map[int][]leafRule{} |
| 49 | for _, r := range rules { |
| 50 | byTop[r.top] = append(byTop[r.top], r) |
| 51 | } |
| 52 | floors := map[string]int{} |
| 53 | for top, in := range byTop { |
| 54 | if len(in) != 1 || in[0].context != touchContext || !slices.Equal(in[0].decls, [][3]string{{"font-size", "max(1rem, 1em)", ""}}) { |
| 55 | continue |
| 56 | } |
| 57 | if _, dup := floors[in[0].selector]; dup { |
| 58 | t.Errorf("two zoom floors for %q", in[0].selector) |
| 59 | } |
| 60 | floors[in[0].selector] = top |
| 61 | } |
| 62 | if len(floors) != 4 { |
| 63 | t.Errorf("tokens.css carries %d zoom floors, want 4 (bare elements, input, textarea, search): %v", len(floors), floors) |
| 64 | } |
| 65 | if _, ok := floors[bareZoomFloor]; !ok { |
| 66 | t.Errorf("no bare-element floor spelled %q", bareZoomFloor) |
| 67 | } |
| 68 | if !strings.HasPrefix(bareZoomFloor, ":where(") || !strings.HasSuffix(bareZoomFloor, ")") || specificity(bareZoomFloor) != [3]int{} { |
| 69 | t.Errorf("the bare-element floor is not wholly inside :where(): %v", specificity(bareZoomFloor)) |
| 70 | } |
| 71 | base := func(prelude string) int { |
| 72 | for _, r := range rules { |
| 73 | if r.context == "" && r.selector == prelude { |
| 74 | return r.top |
| 75 | } |
| 76 | } |
| 77 | t.Fatalf("tokens.css has no top-level rule %q", prelude) |
| 78 | return 0 |
| 79 | } |
| 80 | for _, c := range []struct{ floor, after, before string }{ |
| 81 | {".rst-input, [rst-input]", ".rst-input, [rst-input]", `.rst-input--primary, [rst-input~="primary"]`}, |
| 82 | {".rst-textarea, [rst-textarea]", ".rst-textarea, [rst-textarea]", ""}, |
| 83 | {`.rst-search input[type="search"], [rst-search] input[type="search"]`, `.rst-search input[type="search"], [rst-search] input[type="search"]`, ""}, |
| 84 | } { |
| 85 | at, ok := floors[c.floor] |
| 86 | if !ok { |
| 87 | t.Errorf("no zoom floor for %q", c.floor) |
| 88 | continue |
| 89 | } |
| 90 | switch after := base(c.after); { |
| 91 | case at < after: |
| 92 | t.Errorf("the floor for %q is not directly after the rule that resets its font: it comes before it, so `font: inherit` switches it off", c.floor) |
| 93 | case at > after+1: |
| 94 | var between []string |
| 95 | for top := after + 1; top < at; top++ { |
| 96 | for _, r := range byTop[top] { |
| 97 | between = append(between, r.selector) |
| 98 | } |
| 99 | } |
| 100 | t.Errorf("the floor for %q is not directly after the rule that resets its font; between them: %q", c.floor, between) |
| 101 | } |
| 102 | if c.before != "" && at > base(c.before) { |
| 103 | t.Errorf("the floor for %q comes after %q, so it overrides the field that is big on purpose", c.floor, c.before) |
| 104 | } |
| 105 | } |
| 106 | // The block this replaces: one pointer-only query sizing the |
| 107 | // components at a weight that beat the primary field. |
| 108 | if slices.ContainsFunc(rules, func(r leafRule) bool { |
| 109 | return strings.Contains(r.context, "@media (pointer: coarse)") && r.context != touchContext |
| 110 | }) { |
| 111 | t.Error("tokens.css still has a pointer-only query; every touch rule uses the one query of §1.1") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // The type step itself (§1.2), read as text so a desktop-only edit |
| 116 | // cannot move it: the four tokens, in the touch query, at :root. |
| 117 | func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) { |
| 118 | // Read as declarations: a token or a rule in a comment or a string |
| 119 | // is not one, and a rule's body ended at its first }, quoted or not. |
| 120 | css := string(fiesta.TokensCSS()) |
| 121 | decls, _ := parseDeclarations(t, "tokens.css", css) |
| 122 | touch := declaredAt(decls, touchContext, ":root") |
| 123 | if len(touch) == 0 { |
| 124 | t.Fatal("no :root block inside the touch query") |
| 125 | } |
| 126 | for _, want := range [][2]string{{"--rst-fs-lg", "1.1875rem"}, {"--rst-fs-base", "1rem"}, {"--rst-fs-sm", "0.875rem"}, {"--rst-fs-xs", "0.8125rem"}} { |
| 127 | if touch[want[0]] != want[1] { |
| 128 | t.Errorf("the touch :root block sets %s to %q, want %s", want[0], touch[want[0]], want[1]) |
| 129 | } |
| 130 | } |
| 131 | if !slices.ContainsFunc(decls, func(d declaration) bool { return d.Property == "--rst-tap" && fullValue(d) == "2.75rem" }) { |
| 132 | t.Error("--rst-tap: 2.75rem is not declared") |
| 133 | } |
| 134 | // The two components that spelled the xs step as a literal paint |
| 135 | // with the token now, or they would stay 11.5px on a phone. |
| 136 | rules := leafRules(t, css) |
| 137 | for _, sel := range []string{".rst-lrow--head, [rst-lrow~=\"head\"]", ".rst-tip::after, [rst-tip]::after"} { |
| 138 | i := slices.IndexFunc(rules, func(r leafRule) bool { return r.selector == sel }) |
| 139 | if i < 0 { |
| 140 | t.Fatalf("no rule %q", sel) |
| 141 | } |
| 142 | r := rules[i] |
| 143 | literal := slices.ContainsFunc(r.decls, func(d [3]string) bool { return strings.Contains(d[1], "0.71875rem") }) |
| 144 | if literal || !r.sets("font-size", "var(--rst-fs-xs)") { |
| 145 | t.Errorf("%s does not paint with var(--rst-fs-xs): %v", sel, r.decls) |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // TestTheTargetFloorIsOneToken pins the floor's numbers as text, so a |
| 151 | // desktop-only edit cannot move the phone's floor or the reverse: every |
| 152 | // floor reads --rst-target, 2rem (32px) at :root and raised to --rst-tap |
| 153 | // (44px) by a :root block in the touch query. A floor that read |
| 154 | // --rst-tap directly would be 44px on a desktop too, or, moved out of |
| 155 | // the touch query, the 32px desktop floor would never reach it. |
| 156 | func TestTheTargetFloorIsOneToken(t *testing.T) { |
| 157 | // Read as declarations, where the cascade reads them: a :root |
| 158 | // block found by a pattern ended at its first }, quoted or not. |
| 159 | css := string(fiesta.TokensCSS()) |
| 160 | decls, _ := parseDeclarations(t, "tokens.css", css) |
| 161 | if declaredAt(decls, "", ":root")["--rst-target"] != "2rem" { |
| 162 | t.Error("the top-level :root does not declare --rst-target: 2rem, the desktop floor") |
| 163 | } |
| 164 | if declaredAt(decls, touchContext, ":root")["--rst-target"] != "var(--rst-tap)" { |
| 165 | t.Error("no :root block in the touch query raises --rst-target to var(--rst-tap)") |
| 166 | } |
| 167 | sizes := []string{"min-block-size", "min-inline-size", "block-size", "inline-size"} |
| 168 | // A size whose value is var(--rst-tap) itself; the calendar's |
| 169 | // grid is seven taps wide, which is a width, not a floor. |
| 170 | for _, d := range decls { |
| 171 | refs := varRefs(d.Value) |
| 172 | if slices.Contains(sizes, d.Property) && strings.HasPrefix(d.Value, "var(") && len(refs) > 0 && refs[0].name == "--rst-tap" { |
| 173 | t.Errorf("%s sizes a control with --rst-tap; floors read --rst-target, so the desktop gets its own floor", d) |
| 174 | } |
| 175 | } |
| 176 | // Each control in the inventory has its floor, read through the |
| 177 | // token, outside the touch query: a floor only the touch query |
| 178 | // holds leaves the control at its old size on a desktop. Named |
| 179 | // rather than counted, so a control that loses its floor fails by |
| 180 | // name, and two floors merged into one rule pass. |
| 181 | var everyWidth []leafRule |
| 182 | for _, r := range leafRules(t, css) { |
| 183 | if !slices.Contains(strings.Split(r.context, " > "), touchContext) { |
| 184 | everyWidth = append(everyWidth, r) |
| 185 | } |
| 186 | } |
| 187 | sized := func(r leafRule) bool { |
| 188 | for _, p := range sizes { |
| 189 | if r.sets(p, "var(--rst-target)") { |
| 190 | return true |
| 191 | } |
| 192 | } |
| 193 | return false |
| 194 | } |
| 195 | for _, e := range tapInventory { |
| 196 | if why, ok := floorElsewhere[e.Key]; ok { |
| 197 | if why == "" { |
| 198 | t.Errorf("floorElsewhere names %q with no reason", e.Key) |
| 199 | } |
| 200 | continue |
| 201 | } |
| 202 | found := false |
| 203 | for _, r := range everyWidth { |
| 204 | if !sized(r) { |
| 205 | continue |
| 206 | } |
| 207 | for _, s := range splitSelectorList(r.selector) { |
| 208 | found = found || strings.Contains(s, e.Key) |
| 209 | } |
| 210 | } |
| 211 | if !found { |
| 212 | t.Errorf("%s (%s): no rule outside the touch query sizes it with var(--rst-target)", e.Idiom, e.Key) |
| 213 | } |
| 214 | } |
| 215 | for key := range floorElsewhere { |
| 216 | found := false |
| 217 | for _, e := range tapInventory { |
| 218 | found = found || e.Key == key |
| 219 | } |
| 220 | if !found { |
| 221 | t.Errorf("floorElsewhere names %q, which is not in the inventory", key) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // floorBlock cuts the every-width floor block out of tokens.css: the |
| 227 | // css without it, and the block's own text. |
| 228 | func floorBlock(t *testing.T) (without, block string) { |
| 229 | t.Helper() |
| 230 | css := string(fiesta.TokensCSS()) |
| 231 | start := commentAt(t, css, "/* ── Target floors, at every width") |
| 232 | end := commentAt(t, css, "/* ---------------------------------------------------------------------\n Utilities, last on purpose.") |
| 233 | if start < 0 || end < start { |
| 234 | t.Fatal("tokens.css has no floor block between its heading and the utilities; the markers this test cuts on have moved") |
| 235 | } |
| 236 | return css[:start] + css[end:], css[start:end] |
| 237 | } |
| 238 | |
| 239 | // floorDeclarations is every property the every-width floor block may |
| 240 | // set, with the values it may set it to (none listed: any value) and |
| 241 | // why the floor needs it. The block reaches a desktop, where a control |
| 242 | // already over 32px must come out of it as it went in, so anything that |
| 243 | // is not a size or what makes the size take hold belongs in the touch |
| 244 | // query instead. |
| 245 | var floorDeclarations = map[string]struct { |
| 246 | values []string |
| 247 | why string |
| 248 | }{ |
| 249 | "min-block-size": {nil, "the floor itself"}, |
| 250 | "min-inline-size": {nil, "the floor itself"}, |
| 251 | "block-size": {nil, "the floor, on a control whose size is fixed (an icon button)"}, |
| 252 | "inline-size": {nil, "the floor, on a control whose size is fixed (an icon button)"}, |
| 253 | "box-sizing": {[]string{"border-box"}, "the floor is the whole control; on the content box its padding and border landed on top, and a 33.8px summary became 44.8"}, |
| 254 | "display": {[]string{"inline-flex"}, "min sizes do nothing on an inline box, so a control born inline (a link) becomes an inline-flex box the floor can grow"}, |
| 255 | "align-items": {[]string{"center"}, "centres the content of an inline-flex control in the box the floor grows"}, |
| 256 | "justify-content": {[]string{"center"}, "the same, across: an icon-only link or the row checkbox's 16px box in the label the floor widens"}, |
| 257 | "align-content": {[]string{"center"}, "centres a block's content in the floor without making it a flex box, which would drop the spaces in its label"}, |
| 258 | "column-gap": {nil, "puts back the space a flex box drops between an icon and its word; acts only where the floor made the flex box"}, |
| 259 | "padding-block": {[]string{"0"}, "the search box: its input and clear link are the targets and fill its height at the floor; with its own block padding on top a desktop box grew from 35.6 to 43.6"}, |
| 260 | "align-self": {[]string{"stretch"}, "the search input stretches to the box's height, so the floor reaches the target a tap focuses"}, |
| 261 | "flex": {[]string{"none"}, "the bulk bar's close link, which its flex row shrank past the floor"}, |
| 262 | } |
| 263 | |
| 264 | // TestTheFloorBlockCarriesOnlySizes reads every declaration in the |
| 265 | // every-width floor block against floorDeclarations. The browser check |
| 266 | // beside it (TestTheFloorChangesNothingButSizeOnADesktop) compares the |
| 267 | // properties and boxes it knows to read, so a floor rule that also |
| 268 | // moved a control (position: relative and an inset, a margin) left its |
| 269 | // size and every property it reads alone, and passed. A property is |
| 270 | // either here with its reason, or it goes in the touch query. |
| 271 | func TestTheFloorBlockCarriesOnlySizes(t *testing.T) { |
| 272 | _, block := floorBlock(t) |
| 273 | rules := leafRules(t, block) |
| 274 | if len(rules) < 30 { |
| 275 | t.Fatalf("read %d rules in the floor block; the parse no longer finds them", len(rules)) |
| 276 | } |
| 277 | used := map[string]bool{} |
| 278 | for _, r := range rules { |
| 279 | for _, d := range r.decls { |
| 280 | prop, value := d[0], d[1] |
| 281 | if d[2] != "" { |
| 282 | value += " " + d[2] |
| 283 | } |
| 284 | used[prop] = true |
| 285 | allowed, ok := floorDeclarations[prop] |
| 286 | if !ok { |
| 287 | t.Errorf("%s sets %s: %s in the floor block, which reaches a desktop; a declaration that is not a size or what makes one take hold goes in the touch query", firstSelector(r.selector), prop, value) |
| 288 | continue |
| 289 | } |
| 290 | if allowed.values != nil && !slices.Contains(allowed.values, value) { |
| 291 | t.Errorf("%s sets %s: %s in the floor block; the floor needs %s only as %s", firstSelector(r.selector), prop, value, prop, strings.Join(allowed.values, " or ")) |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | // An entry nothing uses would let the next rule set it unexamined. |
| 296 | for prop := range floorDeclarations { |
| 297 | if !used[prop] { |
| 298 | t.Errorf("floorDeclarations allows %s, which no rule in the floor block sets; take it out", prop) |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // firstSelector names a rule by the first selector in its list, enough |
| 304 | // to find it in the block. |
| 305 | func firstSelector(sel string) string { |
| 306 | if s := splitSelectorList(sel); len(s) > 0 { |
| 307 | return s[0] |
| 308 | } |
| 309 | return sel |
| 310 | } |
| 311 | |
| 312 | // floorElsewhere are the inventory's controls whose size does not come |
| 313 | // from --rst-target, each with where it does come from. |
| 314 | var floorElsewhere = map[string]string{ |
| 315 | "[rst-shell-profile] > summary": "the avatar inside it, with the summary's padding, is the target", |
| 316 | "[rst-shell-profile][open] > summary": "the same avatar, open", |
| 317 | "[rst-shell-nav] > a": "the phone index's rows are 3rem, over the floor, in their own query", |
| 318 | "[rst-shell-back] > a": "its own 2.75rem height is over the floor; only its width reads the token", |
| 319 | } |
| 320 | |
| 321 | // tapEntry is one row of spec §1.4's table: Key is a substring of the |
| 322 | // attribute spelling of the rules that style the idiom, Marker a string |
| 323 | // the sizing fixture (or the modal sample) must contain for the idiom to |
| 324 | // be measured at all (for controls a script draws at run time, the |
| 325 | // attribute that asks for them), and Probe the selector of the element |
| 326 | // the target drive measures for it. The drive fails if no element |
| 327 | // matching Probe was measured, so an idiom cannot drop out of the |
| 328 | // measurement silently. Probe may be prefixed "narrow:" (rendered only |
| 329 | // below 800px, so not expected at 1024), "modal:" (on the modal page), |
| 330 | // or be "elsewhere:<Test>" for a control a drive of its own measures in |
| 331 | // a state the sizing page never reaches. |
| 332 | type tapEntry struct{ Key, Idiom, Marker, Probe string } |
| 333 | |
| 334 | var tapInventory = []tapEntry{ |
| 335 | {"[rst-btn]", "Buttons, all sizes", "rst-btn", "[rst-btn]"}, |
| 336 | {"[rst-input]", "Inputs and selects", "rst-input", "[rst-input]"}, |
| 337 | {"[rst-textarea]", "Textareas", "rst-textarea", "[rst-textarea]"}, |
| 338 | {"[rst-search]", "Search box", "rst-search", "[rst-search] input[type=search]"}, |
| 339 | {"[rst-search-clear]", "Search box: its clear link", "rst-search-clear", "[rst-search-clear]"}, |
| 340 | {"[rst-ftok] a", "Filter chip's remove link", "rst-ftok", "[rst-ftok] a"}, |
| 341 | {"[rst-help]", "Help link", "rst-help", "[rst-help]"}, |
| 342 | {"[rst-dropdown] > summary", "Dropdown summaries (list-bar, header, account, locale)", "rst-dropdown", "[rst-dropdown] > summary"}, |
| 343 | {"[rst-menu-group] > summary", "Nested menu-group summaries", "rst-menu-group", "[rst-menu-group] > summary"}, |
| 344 | {"[rst-dropdown-menu] a", "Menu items: dropdown", "rst-dropdown-menu", "[rst-dropdown-menu] a"}, |
| 345 | {"[rst-dropdown-menu] button", "Menu items: dropdown buttons", "rst-dropdown-menu", "[rst-dropdown-menu] button"}, |
| 346 | {"[rst-row-menu-panel] a", "Menu items: row menu", "rst-row-menu-panel", "[rst-row-menu-panel] a"}, |
| 347 | {"[rst-row-menu-panel] button", "Menu items: row menu buttons", "rst-row-menu-panel", "[rst-row-menu-panel] button"}, |
| 348 | {"[rst-locale] button", "Menu items: locale", "rst-locale", "[rst-locale] button"}, |
| 349 | {"[rst-combo-option]", "Combobox options", "data-rst-select", "[rst-combo-option]"}, |
| 350 | {"[rst-dtp-row]", "Date-picker rows", "data-rst-date", "[rst-dtp-row]"}, |
| 351 | {"[rst-dtp-pick]", "Date-picker pick button", "data-rst-date", "[rst-dtp-pick]"}, |
| 352 | {"[rst-cal-nav]", "Calendar nav", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"}, |
| 353 | {"[rst-cal-day]", "Calendar days", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"}, |
| 354 | {"[rst-row-action]", "Row action pill", "rst-row-action", "[rst-row-action]"}, |
| 355 | {"[rst-row-menu] > summary", "Row kebab", "rst-row-menu", "[rst-row-menu] > summary"}, |
| 356 | {"[rst-selbox]", "Row checkbox", "rst-selbox", "[rst-selbox] input"}, |
| 357 | {"[rst-person]", "Standalone person link", "rst-person", "a[rst-person]"}, |
| 358 | {"[rst-pagination] > a", "Pagination chips", "rst-pagination", "[rst-pagination] > a"}, |
| 359 | {"[rst-seg-tabs] a", "Segmented tabs", "rst-seg-tabs", "[rst-seg-tabs] a"}, |
| 360 | {"[rst-seg-tabs] > button", "Segmented tabs: buttons", "rst-seg-tabs", "[rst-seg-tabs] > button"}, |
| 361 | {"[rst-side-nav] a", "Side nav links", "rst-side-nav", "[rst-side-nav] a"}, |
| 362 | {"[rst-switch]", "Switch", "rst-switch", "[rst-switch] input"}, |
| 363 | {"[rst-choice-cards] label", "Choice cards", "rst-choice-cards", "[rst-choice-cards] input"}, |
| 364 | {"[rst-tblock-head]", "Toggle-block head", "rst-tblock-head", "[rst-tblock-head] input"}, |
| 365 | {"[rst-bulkbar-close]", "Bulk bar: close", "rst-bulkbar-close", "[rst-bulkbar-close]"}, |
| 366 | {"[rst-bulkbar-escalate]", "Bulk bar: escalate link", "rst-bulkbar-escalate", "[rst-bulkbar-escalate]"}, |
| 367 | {"[rst-modal-close]", "Modal close", "rst-modal-close", "modal:[rst-modal-close]"}, |
| 368 | {"[rst-modal-panel] > nav a", "Modal panel nav links", "rst-modal-panel", "modal:[rst-modal-panel] > nav a"}, |
| 369 | {"[rst-back-nav] a", "Back-nav link", "rst-back-nav", "[rst-back-nav] a"}, |
| 370 | {"[rst-signin-providers] > a", "Sign-in: the link under the provider buttons", "rst-signin-providers", "[rst-signin-providers] > a"}, |
| 371 | {"[rst-shell-brand]", "Shell: brand", "rst-shell-brand", "[rst-shell-brand]"}, |
| 372 | {"[rst-shell-nav] a", "Shell: nav links", "rst-shell-nav", "[rst-shell-nav] a"}, |
| 373 | {"[rst-shell-menu] > summary", "Shell: Menu summary", "rst-shell-menu", "narrow:[rst-shell-menu] > summary"}, |
| 374 | {"[rst-shell-menu][open] > summary", "Shell: the open Menu summary", "rst-shell-menu", "elsewhere:TestTheCardsControlsAreTaps"}, |
| 375 | {"[rst-shell-account] > summary", "Shell: the account summary inside the card", "rst-shell-account", "[rst-shell-account] > summary"}, |
| 376 | {"[rst-shell-chrome] > summary", "Legacy sidebar drawer summary", "rst-shell-chrome", "narrow:[rst-shell-chrome] > summary"}, |
| 377 | {"[rst-shell-profile] > summary", "Shell: the profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"}, |
| 378 | {"[rst-shell-profile][open] > summary", "Shell: the open profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"}, |
| 379 | {"[rst-shell-back] > a", "Shell: the back control", "rst-shell-back", "narrow:[rst-shell-back] > a"}, |
| 380 | {"[rst-shell-nav] > a", "Shell: the index rows (3rem)", "rst-shell-nav", "elsewhere:TestTheIndexRowsRoundEachRunOfLinks"}, |
| 381 | } |
| 382 | |
| 383 | // tapExempt are the interactive rules that are deliberately not held to |
| 384 | // the floor, each with its reason. Nothing else may be added here: a |
| 385 | // control that does not fit the floor gets a rule in the floor block. |
| 386 | var tapExempt = map[string]string{ |
| 387 | "[rst-row-main] > a": "the stretched primary link: the tap target is the whole row (spec §2)", |
| 388 | "[rst-media-name] > a": "a media-grid tile's stretched name link: the tap target is the whole tile, frame and caption", |
| 389 | "a.rst-nm": "the list grid's stretched identity link: the tap target is the whole row (spec §2)", |
| 390 | "[rst-lrow] > a[rst-person]": "the list grid's stretched person link: the tap target is the whole row (spec §2)", |
| 391 | ".rst-lrow > a.rst-person": "the class spelling of the same stretched person link", |
| 392 | "[rst-no-match] a": "a link in running text, WCAG 2.5.8's inline exception (spec §1.4, Exempt)", |
| 393 | ".rst-sr-only:focus-visible": "the hidden search submit: it exists for the keyboard and un-hides on focus; no pointer reaches it", |
| 394 | } |
| 395 | |
| 396 | // iconFree are the inventory's controls that hold no icon of their own, |
| 397 | // so the rule sizing an icon inside a control need not name them. |
| 398 | var iconFree = map[string]string{ |
| 399 | "[rst-input]": "a field: its text, not an icon", |
| 400 | "[rst-textarea]": "a field: its text, not an icon", |
| 401 | "[rst-cal-day]": "a day's number", |
| 402 | "[rst-selbox]": "a checkbox", |
| 403 | "[rst-person]": "an avatar, which is not an icon", |
| 404 | "[rst-shell-profile] > summary": "an avatar, which is not an icon", |
| 405 | "[rst-shell-profile][open] > summary": "an avatar, which is not an icon", |
| 406 | "[rst-shell-menu][open] > summary": "the same summary as [rst-shell-menu] > summary, open", |
| 407 | "[rst-shell-nav] > a": "the index rows, which [rst-shell-nav] a names", |
| 408 | } |
| 409 | |
| 410 | // TestEveryControlSizesTheIconInIt holds the two lists together: an |
| 411 | // icon is 16px inside a control and follows its text everywhere else, |
| 412 | // and "a control" is the inventory the target floor implements. A |
| 413 | // control added to the floor and not to the icon rule would show its |
| 414 | // icon at whatever size its text happens to be, which is what the 16px |
| 415 | // rule exists to stop. |
| 416 | func TestEveryControlSizesTheIconInIt(t *testing.T) { |
| 417 | var sel string |
| 418 | for _, r := range leafRules(t, string(fiesta.TokensCSS())) { |
| 419 | if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && r.sets("block-size", "1rem") { |
| 420 | sel = collapseSpace(r.selector) |
| 421 | } |
| 422 | } |
| 423 | if sel == "" { |
| 424 | t.Fatal("tokens.css has no :where(...) .icon rule sizing icons in controls at 1rem") |
| 425 | } |
| 426 | for _, e := range tapInventory { |
| 427 | if _, free := iconFree[e.Key]; free { |
| 428 | continue |
| 429 | } |
| 430 | if !strings.Contains(sel, e.Key) { |
| 431 | t.Errorf("%s (%s) is a control the floor holds, and the rule that sizes an icon inside a control does not name it", e.Idiom, e.Key) |
| 432 | } |
| 433 | } |
| 434 | for key := range iconFree { |
| 435 | found := false |
| 436 | for _, e := range tapInventory { |
| 437 | found = found || e.Key == key |
| 438 | } |
| 439 | if !found { |
| 440 | t.Errorf("iconFree names %q, which is not in the inventory", key) |
| 441 | } |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // endsOnControlElement reports whether a selector's last compound is an |
| 446 | // a, button, summary or label: the rule styles that element. It reads |
| 447 | // the selector with cssSpan, so a space or a bracket inside a quoted |
| 448 | // attribute value is text, not the start of a compound. |
| 449 | func endsOnControlElement(sel string) bool { |
| 450 | depth, last := 0, 0 |
| 451 | for i := 0; i < len(sel); { |
| 452 | end, _, err := cssSpan(sel, i) |
| 453 | if err != nil { |
| 454 | return false |
| 455 | } |
| 456 | switch c := sel[i]; c { |
| 457 | case '(', '[': |
| 458 | depth++ |
| 459 | case ')', ']': |
| 460 | depth-- |
| 461 | case ' ', '>', '+', '~': |
| 462 | if depth == 0 { |
| 463 | last = i + 1 |
| 464 | } |
| 465 | } |
| 466 | i = end |
| 467 | } |
| 468 | return regexp.MustCompile(`^(a|button|summary|label)($|[^a-zA-Z0-9_-])`).MatchString(strings.TrimSpace(sel[last:])) |
| 469 | } |
| 470 | |
| 471 | // interactiveCSSRules is every innermost rule in css that carries cursor: |
| 472 | // pointer or styles an a, button, summary or label. |
| 473 | func interactiveCSSRules(t testing.TB, css string) []leafRule { |
| 474 | t.Helper() |
| 475 | var out []leafRule |
| 476 | for _, r := range leafRules(t, css) { |
| 477 | hit := slices.Contains(r.decls, [3]string{"cursor", "pointer", ""}) || slices.Contains(r.decls, [3]string{"cursor", "pointer", "!important"}) |
| 478 | for _, s := range splitSelectorList(r.selector) { |
| 479 | hit = hit || endsOnControlElement(s) |
| 480 | } |
| 481 | if hit { |
| 482 | out = append(out, r) |
| 483 | } |
| 484 | } |
| 485 | return out |
| 486 | } |
| 487 | |
| 488 | // TestEveryInteractiveRuleIsInTheTapInventory is §10.1's inventory |
| 489 | // gate: a new control cannot arrive in tokens.css without a decision |
| 490 | // about its touch size, because its rule has to name an idiom in the |
| 491 | // table above (or an exemption with a reason), and every idiom in the |
| 492 | // table has to be on the page the browser drive measures. |
| 493 | func TestEveryInteractiveRuleIsInTheTapInventory(t *testing.T) { |
| 494 | rules := interactiveCSSRules(t, string(fiesta.TokensCSS())) |
| 495 | if len(rules) < 40 { |
| 496 | t.Fatalf("found only %d interactive rules in tokens.css; the reader is broken, not the file", len(rules)) |
| 497 | } |
| 498 | for _, r := range rules { |
| 499 | named := false |
| 500 | for _, s := range splitSelectorList(r.selector) { |
| 501 | for _, e := range tapInventory { |
| 502 | named = named || strings.Contains(s, e.Key) |
| 503 | } |
| 504 | for key := range tapExempt { |
| 505 | named = named || strings.Contains(s, key) |
| 506 | } |
| 507 | } |
| 508 | if !named { |
| 509 | t.Errorf("tokens.css styles a control no row of §1.4's inventory names:\n\t%s\n\tadd a floor rule and a tapInventory row, or say why it is exempt", r.selector) |
| 510 | } |
| 511 | } |
| 512 | // Where rastrillo looked in its sizing fixture plus the modal sample, this |
| 513 | // looks in the sizing fixture plus every example piece, the modal's |
| 514 | // among them: a marker no example carries is one no drive can reach. |
| 515 | fixture, err := os.ReadFile("testdata/fixtures/sizing.html") |
| 516 | if err != nil { |
| 517 | t.Fatal(err) |
| 518 | } |
| 519 | pieces, err := example.All(fiesta.FS()) |
| 520 | if err != nil { |
| 521 | t.Fatal(err) |
| 522 | } |
| 523 | var page strings.Builder |
| 524 | page.Write(fixture) |
| 525 | for _, p := range pieces { |
| 526 | page.WriteString(p.Markup) |
| 527 | } |
| 528 | for _, e := range tapInventory { |
| 529 | if !strings.Contains(page.String(), e.Marker) { |
| 530 | t.Errorf("%s (%s): neither the sizing fixture nor any example renders anything carrying %q, so the drive never measures it", e.Idiom, e.Key, e.Marker) |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | |