Read what tokens.css must hold from its rules, not its text
The gates that ask whether tokens.css holds a rule, a declaration or a token searched its text for it, so a copy in a comment or a string passed them, and those that read a block cut it at the first } or by a pattern over braces. Each now asks the readers the gates share. Presence of a selector is asked of cssRules (ruleSelectors); a declaration or a rule's body of leafRules or declarationsOf; a token reference of varRefs, which reads var() with cssSpan and braceMatchEnd, so a quoted var() is text and var( --rst-x ) spaced is still a reference. That covers the embedded-token list, the token-reference gate, the modal reset, the focus ring, the sr-only order, the colour-literal gate (which read line by line and missed a value written over two lines), the hidden buttons, the stage art, the sign-in border, the face shade, the forced-colours and print blocks, the retired header rule, the zoom floors, the type step, the target floor, the search landmark, the profile placeholder, the anchor contract and styleProperties. The forced-colours and print blocks are the last top-level block of each query, and "after the print block" is any later top-level statement. A zoom floor is a touch query holding one rule, and "directly after" is the next statement. The header-rule gate reads each selector of a list, so a pseudo-element after :is(a, b) is no longer missed, and reads :after as well as ::after. stripHas, which builds tokens.css as an engine without :has() sees it, now splits rules with cssItems: the brace pattern ended a rule at a quoted } and kept the :has() rule. On tokens.css it drops the same 48 rules, and what is left reads to the same 5825 declarations. blockBody and the text forms of touchQuery are gone. Red: TestVarRefsReadAStringAsText's value and the quoted-brace probe in TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas, fed to the old pattern and the old stripHas, read wrongly. Mutations, each red and reverted: varRefs reading inside strings (U1), no fallback ever (U2), var matched in one case only (U3), stripHas dropping no :has() rule (U4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+465
−217
check/console_shell_browser_test.go+57 −15check/contrast_face_test.go+4 −1check/contrast_test.go+18 −31check/coverage_test.go+6 −4check/css_test.go+174 −61check/depth_media_test.go+75 −25check/header_rule_test.go+27 −14check/markup_v3_browser_test.go+9 −4check/scripts_test.go+2 −1check/sizing_test.go+81 −61check/spelling_test.go+12 −0
Intent
diff --git a/check/console_shell_browser_test.go b/check/console_shell_browser_test.go| index 28ed480..434d6cf 100644 |
| --- a/check/console_shell_browser_test.go |
| +++ b/check/console_shell_browser_test.go |
| @@ -31,7 +31,7 @@ import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| - "regexp" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -603,11 +603,6 @@ func TestTheConsoleRailFitsTheViewport(t *testing.T) { |
| } |
| } |
| -// hasRulePattern matches one CSS rule whose selector list mentions |
| -// :has(). Crude on purpose: it is a simulation of an engine, and the |
| -// engine's rule is coarser than a parser's — see stripHas. |
| -var hasRulePattern = regexp.MustCompile(`(?s)[^{}]*\{[^{}]*\}`) |
| - |
| // stripHas returns tokens.css as an engine that does not implement |
| // :has() would see it. |
| // |
| @@ -622,6 +617,12 @@ var hasRulePattern = regexp.MustCompile(`(?s)[^{}]*\{[^{}]*\}`) |
| // having about `display`, and the rail landed in the right cell only |
| // because grid auto-placement happened to agree with the declared area. |
| // It looked correct. It was luck. |
| +// |
| +// It reads the stylesheet with cssItems, the declaration reader's own |
| +// statement splitter, into the grouping rules (@media, @supports, |
| +// @container) and out again. A pattern over braces ended a rule at a |
| +// brace inside a string, and so dropped the wrong text or kept a |
| +// :has() rule. |
| func stripHas(t testing.TB, css string) (string, int) { |
| t.Helper() |
| dropped := 0 |
| @@ -635,15 +636,46 @@ func stripHas(t testing.TB, css string) (string, int) { |
| // useful thing to say about them — they are not only a gate on the |
| // stylesheet, they are the control on this function. |
| css = stripCSSComments(t, css) |
| - out := hasRulePattern.ReplaceAllStringFunc(css, func(rule string) string { |
| - prelude, _, ok := strings.Cut(rule, "{") |
| - if !ok || !strings.Contains(prelude, ":has(") { |
| - return rule |
| + var strip func(src string) string |
| + strip = func(src string) string { |
| + items, err := cssItems(src) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - dropped++ |
| - return "\n" |
| - }) |
| - return out, dropped |
| + var b strings.Builder |
| + for _, it := range items { |
| + prelude := collapseSpace(it.prelude) |
| + switch { |
| + case !it.hasBlock: |
| + b.WriteString(it.prelude + ";") |
| + case strings.HasPrefix(prelude, "@") && groupingRules[atRuleName(prelude)]: |
| + b.WriteString(it.prelude + "{" + strip(it.block) + "}") |
| + case !strings.HasPrefix(prelude, "@") && selectorHas(prelude): |
| + dropped++ |
| + b.WriteString("\n") |
| + default: |
| + b.WriteString(it.prelude + "{" + it.block + "}") |
| + } |
| + } |
| + return b.String() |
| + } |
| + return strip(css), dropped |
| +} |
| + |
| +// selectorHas reports whether a selector list uses :has(), read with |
| +// cssSpan, so :has( inside a quoted attribute value is text. |
| +func selectorHas(prelude string) bool { |
| + for i := 0; i < len(prelude); { |
| + end, _, err := cssSpan(prelude, i) |
| + if err != nil { |
| + return false |
| + } |
| + if prelude[i] == ':' && strings.HasPrefix(prelude[end:], "has(") { |
| + return true |
| + } |
| + i = end |
| + } |
| + return false |
| } |
| // TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas is the gate on the |
| @@ -687,7 +719,17 @@ func TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas(t *testing.T) { |
| if strings.Contains(legacy, ":has(") { |
| t.Fatal("the :has()-less simulation left a :has() selector in the stylesheet; it is not the engine it claims to simulate") |
| } |
| - consoleRules := strings.Count(legacy, "rst-shell-console") |
| + // And it reads a brace inside a string as text: a pattern over |
| + // braces ended [rst-a]'s rule at the quoted } and kept the :has() |
| + // rule. |
| + probe, n := stripHas(t, `[rst-a] { content: "}"; } [rst-b]:has(i) { color: red; content: "{"; } [rst-c] { color: blue; }`) |
| + if kept := ruleSelectors(t, probe); n != 1 || !slices.Equal(kept, []string{"[rst-a]", "[rst-c]"}) { |
| + t.Fatalf("the :has()-less simulation of a stylesheet with quoted braces dropped %d rules and kept %q; want 1 dropped and [rst-a], [rst-c] kept", n, kept) |
| + } |
| + consoleRules := 0 |
| + for _, sel := range ruleSelectors(t, legacy) { |
| + consoleRules += strings.Count(sel, "rst-shell-console") |
| + } |
| if consoleRules == 0 { |
| t.Fatal("the simulation dropped every console rule in the file; the shell has no stylesheet left and the legs below would be measuring an unstyled document") |
| } |
diff --git a/check/contrast_face_test.go b/check/contrast_face_test.go| index 2823e93..883be1f 100644 |
| --- a/check/contrast_face_test.go |
| +++ b/check/contrast_face_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "fmt" |
| "math" |
| "regexp" |
| + "slices" |
| "strconv" |
| "strings" |
| "testing" |
| @@ -23,7 +24,9 @@ import ( |
| func faceShadeShape(t *testing.T) { |
| t.Helper() |
| - if !regexp.MustCompile(`--rst-btn-face: linear-gradient\(rgb\(255 255 255 / var\(--rst-depth-face, 0\)\), rgb\(0 0 0 / var\(--rst-depth-face, 0\)\)\)`).Match(fiesta.TokensCSS()) { |
| + const shape = "linear-gradient(rgb(255 255 255 / var(--rst-depth-face, 0)), rgb(0 0 0 / var(--rst-depth-face, 0)))" |
| + decls, _ := parseDeclarations(t, "tokens.css", string(fiesta.TokensCSS())) |
| + if !slices.ContainsFunc(decls, func(d declaration) bool { return d.Property == "--rst-btn-face" && fullValue(d) == shape }) { |
| t.Fatal("tokens.css no longer shades a filled face with white at the top and black at the bottom by --rst-depth-face; this test measures that shape") |
| } |
| } |
diff --git a/check/contrast_test.go b/check/contrast_test.go| index 74c8487..82a863f 100644 |
| --- a/check/contrast_test.go |
| +++ b/check/contrast_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "os" |
| "path/filepath" |
| "regexp" |
| + "slices" |
| "strconv" |
| "strings" |
| "testing" |
| @@ -151,34 +152,6 @@ func splitLightDark(v string) (light, dark string, ok bool) { |
| return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true |
| } |
| -// blockBody returns the brace-matched contents following header (which |
| -// must itself end in "{"), starting the search at css[from:]. Simple |
| -// depth counting is enough — no CSS string literal in a theme file |
| -// contains '{' or '}'. where names the file, so a structure failure says |
| -// which theme drifted. |
| -func blockBody(t *testing.T, where, css, header string, from int) string { |
| - t.Helper() |
| - i := strings.Index(css[from:], header) |
| - if i < 0 { |
| - t.Fatalf("%s structure changed: %q not found from offset %d", where, header, from) |
| - } |
| - start := from + i + len(header) |
| - depth := 1 |
| - for j := start; j < len(css); j++ { |
| - switch css[j] { |
| - case '{': |
| - depth++ |
| - case '}': |
| - depth-- |
| - if depth == 0 { |
| - return css[start:j] |
| - } |
| - } |
| - } |
| - t.Fatalf("%s structure changed: unterminated block for %q", where, header) |
| - return "" |
| -} |
| - |
| // themeTokens reads the :root a v2 theme declares and returns it |
| // twice: once resolved for the light scheme, once for dark. |
| func themeTokens(t *testing.T, theme string) map[string]map[string]string { |
| @@ -494,8 +467,20 @@ func TestHighlightColoursPassAgainstTheCodeBackground(t *testing.T) { |
| // measures what the stylesheet actually says rather than a copy of it. |
| func stageArtMix(t *testing.T, part string) (a string, p float64, b string) { |
| t.Helper() |
| - re := regexp.MustCompile(`\[rst-stage-art-` + part + `\] \{[^}]*(?:fill|stroke): color-mix\(in srgb, var\((--rst-[a-z-]+)\) (\d+)%, var\((--rst-[a-z-]+)\)\)`) |
| - m := re.FindStringSubmatch(string(fiesta.TokensCSS())) |
| + // Read off the rule's declarations, so a mix in a comment or a |
| + // string is not the one measured. |
| + mix := regexp.MustCompile(`^color-mix\(in srgb, var\((--rst-[a-z-]+)\) (\d+)%, var\((--rst-[a-z-]+)\)\)$`) |
| + var m []string |
| + for _, r := range leafRules(t, string(fiesta.TokensCSS())) { |
| + if m != nil || !slices.Contains(splitSelectorList(r.selector), "[rst-stage-art-"+part+"]") { |
| + continue |
| + } |
| + for _, prop := range []string{"fill", "stroke"} { |
| + if v, ok := r.value(prop); ok && m == nil { |
| + m = mix.FindStringSubmatch(v) |
| + } |
| + } |
| + } |
| if m == nil { |
| t.Fatalf("tokens.css paints [rst-stage-art-%s] with no color-mix(in srgb, var(--rst-…) N%%, var(--rst-…))", part) |
| } |
| @@ -531,7 +516,9 @@ func TestTheSigninCardStandsOutFromTheStageArt(t *testing.T) { |
| // What follows measures --rst-line-strong. Hold the card to it, or a |
| // later edit to the card's border leaves this passing about a colour |
| // nothing paints. |
| - if !regexp.MustCompile(`\[rst-signin\] \{[^}]*border: 1px solid var\(--rst-line-strong\)`).Match(fiesta.TokensCSS()) { |
| + if !slices.ContainsFunc(leafRules(t, string(fiesta.TokensCSS())), func(r leafRule) bool { |
| + return slices.Contains(splitSelectorList(r.selector), "[rst-signin]") && r.sets("border", "1px solid var(--rst-line-strong)") |
| + }) { |
| t.Fatal("tokens.css no longer borders [rst-signin] with 1px solid var(--rst-line-strong), the colour this test measures") |
| } |
| for _, theme := range fiesta.ThemeNames() { |
diff --git a/check/coverage_test.go b/check/coverage_test.go| index 80b7686..1d1b169 100644 |
| --- a/check/coverage_test.go |
| +++ b/check/coverage_test.go |
| @@ -3,6 +3,7 @@ package check |
| import ( |
| "io/fs" |
| "regexp" |
| + "slices" |
| "sort" |
| "strings" |
| "testing" |
| @@ -405,9 +406,9 @@ func TestTheSearchFormSitsInASearchLandmark(t *testing.T) { |
| if !strings.Contains(got, "<search><form") || !strings.Contains(got, "</form></search>") { |
| t.Errorf("the search form is not wrapped in a <search> landmark: %s", got) |
| } |
| - css := string(fiesta.TokensCSS()) |
| + sels := ruleSelectors(t, string(fiesta.TokensCSS())) |
| for _, want := range []string{"[rst-lbar] > search", "[rst-list] > search"} { |
| - if !strings.Contains(css, want) { |
| + if !slices.ContainsFunc(sels, func(s string) bool { return strings.Contains(s, want) }) { |
| t.Errorf("tokens.css has no %q rule; wrapping the form broke the layout it used to size", want) |
| } |
| } |
| @@ -456,8 +457,9 @@ func TestTheSidebarRailFootIsOneProfileMenu(t *testing.T) { |
| } |
| last = at |
| } |
| - css := string(fiesta.TokensCSS()) |
| - if !strings.Contains(css, `[rst-shell-rail-foot]:has([rst-shell-who]:only-child [rst-person-av~="placeholder"])`) { |
| + if !slices.ContainsFunc(ruleSelectors(t, string(fiesta.TokensCSS())), func(s string) bool { |
| + return strings.Contains(s, `[rst-shell-rail-foot]:has([rst-shell-who]:only-child [rst-person-av~="placeholder"])`) |
| + }) { |
| t.Error("tokens.css does not hide the placeholder profile with nothing to choose: an app with no profile, no account and no language would get a button opening an empty card") |
| } |
| } |
diff --git a/check/css_test.go b/check/css_test.go| index 2cae5be..5d6765d 100644 |
| --- a/check/css_test.go |
| +++ b/check/css_test.go |
| @@ -17,10 +17,12 @@ import ( |
| // stylesheets embed, the one-block theme structure, the motion and |
| // scheme gates, the selectors the components rely on, and the markup |
| // contracts the examples hold (the dropdown's native exclusivity, |
| -// the modal as a rendered-open dialog). The CSS readers they share |
| -// (leafRules, selectorList, blockBody) live here and in |
| -// contrast_test.go, on the scanner and bracket reader in |
| -// markuphelp_test.go; the tests over markup read the example state |
| +// the modal as a rendered-open dialog). They read the stylesheets |
| +// with the readers the gates share: cssSpan and the bracket reader in |
| +// markuphelp_test.go, declarationsOf in baseline_test.go, leafRules |
| +// here, cssRules and splitSelectorList in spelling_test.go, and |
| +// themeTokens in contrast_test.go. None searches a stylesheet's text |
| +// for what it must hold. The tests over markup read the example state |
| // the manifest's idiom table names. |
| func TestTokensCSSIsEmbedded(t *testing.T) { |
| @@ -28,16 +30,31 @@ func TestTokensCSSIsEmbedded(t *testing.T) { |
| if len(css) == 0 { |
| t.Fatal("fiesta.TokensCSS() is empty") |
| } |
| - for _, want := range []string{ |
| - "--rst-sp-4", "--rst-fs-base", |
| - "var(--rst-bg)", "var(--rst-text)", "var(--rst-accent)", "var(--rst-font)", |
| - "var(--rst-radius)", "var(--rst-shadow-pop)", |
| - "[rst-status]", ".rst-sr-only", |
| - } { |
| - if !strings.Contains(string(css), want) { |
| - t.Errorf("tokens.css is missing %q", want) |
| + // Each is read where a browser reads it, so one only a comment |
| + // mentions is missing: a declared token, a var() a value reads, |
| + // a selector. |
| + decls, _ := parseDeclarations(t, "tokens.css", string(css)) |
| + declared, reads := map[string]bool{}, map[string]bool{} |
| + for _, d := range decls { |
| + declared[d.Property] = true |
| + for _, ref := range varRefs(d.Value) { |
| + reads["var("+ref.name+")"] = true |
| + } |
| + } |
| + attributes, classes := styledAttributes(t, string(css)), styledClasses(t, string(css)) |
| + for _, want := range []string{"--rst-sp-4", "--rst-fs-base"} { |
| + if !declared[want] { |
| + t.Errorf("tokens.css does not declare %s", want) |
| + } |
| + } |
| + for _, want := range []string{"var(--rst-bg)", "var(--rst-text)", "var(--rst-accent)", "var(--rst-font)", "var(--rst-radius)", "var(--rst-shadow-pop)"} { |
| + if !reads[want] { |
| + t.Errorf("tokens.css never reads %s", want) |
| } |
| } |
| + if !attributes["[rst-status]"] || !classes["rst-sr-only"] { |
| + t.Errorf("tokens.css has no [rst-status] or .rst-sr-only selector") |
| + } |
| // The colour blocks are the theme's now. tokens.css naming one again |
| // means a palette leaked back into the structural file, where a |
| // theme swap could no longer reach it. The shape tokens joined them |
| @@ -628,24 +645,30 @@ func TestTokensCSSIsSelfContained(t *testing.T) { |
| // carries a fallback is an app-settable hook (--rst-cols) and is fine |
| // undeclared. |
| func TestEveryTokenReferenceIsDeclared(t *testing.T) { |
| - decl := regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`) |
| - ref := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)\s*\)`) |
| - // Comments out first: a "--rst-x:" in prose would otherwise count as |
| - // a declaration, and these files are mostly prose. |
| - strip := func(b []byte) string { return stripCSSComments(t, string(b)) } |
| - tokens := strip(fiesta.TokensCSS()) |
| - declared := map[string]bool{} |
| - for _, m := range decl.FindAllStringSubmatch(tokens, -1) { |
| - declared[m[1]] = true |
| - } |
| - inEvery := map[string]int{} |
| + // Read as declarations, not text: a "--rst-x:" in prose or in a |
| + // string would otherwise count as a declaration, and these files |
| + // are mostly prose. var() is read with varRefs, so var( --rst-x ) |
| + // spaced is still a reference without a fallback. |
| + tokens, _ := parseDeclarations(t, "tokens.css", string(fiesta.TokensCSS())) |
| + sheets := map[string][]declaration{"tokens.css": tokens} |
| for _, name := range fiesta.ThemeNames() { |
| theme, _ := fiesta.ThemeCSS(name) |
| + where := "themes/" + name + ".css" |
| + sheets[where] = themeDeclarations(t, where, string(theme)) |
| + } |
| + declared := map[string]bool{} |
| + inEvery := map[string]int{} |
| + for file, decls := range sheets { |
| seen := map[string]bool{} |
| - for _, m := range decl.FindAllStringSubmatch(strip(theme), -1) { |
| - if !seen[m[1]] { |
| - seen[m[1]] = true |
| - inEvery[m[1]]++ |
| + for _, d := range decls { |
| + if !strings.HasPrefix(d.Property, "--rst-") { |
| + continue |
| + } |
| + if file == "tokens.css" { |
| + declared[d.Property] = true |
| + } else if !seen[d.Property] { |
| + seen[d.Property] = true |
| + inEvery[d.Property]++ |
| } |
| } |
| } |
| @@ -656,20 +679,87 @@ func TestEveryTokenReferenceIsDeclared(t *testing.T) { |
| } |
| // A theme derives tokens from tokens (--rst-header-rule mixes the |
| // accent), so its references are held to the same bar. |
| - sources := map[string]string{"tokens.css": tokens} |
| - for _, name := range fiesta.ThemeNames() { |
| - theme, _ := fiesta.ThemeCSS(name) |
| - sources["themes/"+name+".css"] = strip(theme) |
| - } |
| - for file, css := range sources { |
| + for _, file := range sortedKeys(sheets) { |
| reported := map[string]bool{} |
| - for _, m := range ref.FindAllStringSubmatch(css, -1) { |
| - if !declared[m[1]] && !reported[m[1]] { |
| - reported[m[1]] = true |
| - t.Errorf("%s uses var(%s), which neither tokens.css nor every theme declares", file, m[1]) |
| + for _, d := range sheets[file] { |
| + for _, ref := range varRefs(d.Value) { |
| + if !ref.fallback && strings.HasPrefix(ref.name, "--rst-") && !declared[ref.name] && !reported[ref.name] { |
| + reported[ref.name] = true |
| + t.Errorf("%s uses var(%s), which neither tokens.css nor every theme declares", file, ref.name) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// varRefs reads a var() as a browser does. A pattern over the text |
| +// took var(--rst-a) quoted in a string for a reference, missed |
| +// var( --rst-b ) for its spacing, and so could pass a token nobody |
| +// declares or fail one nobody reads. |
| +func TestVarRefsReadAStringAsText(t *testing.T) { |
| + got := varRefs(`"var(--rst-a)" var( --rst-b ) var(--rst-c, var(--rst-d)) VAR(--rst-e,)`) |
| + want := []varRef{{"--rst-b", false}, {"--rst-c", true}, {"--rst-d", false}, {"--rst-e", true}} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("varRefs = %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +// varRef is one var() a value reads: the property it names, and |
| +// whether it carries a fallback. |
| +type varRef struct { |
| + name string |
| + fallback bool |
| +} |
| + |
| +// varRefs is every var() in value, a var() inside another's fallback |
| +// included, read with cssSpan and braceMatchEnd: a var( inside a |
| +// string is text, and spacing inside the brackets changes nothing. |
| +func varRefs(value string) []varRef { |
| + var out []varRef |
| + for i := 0; i < len(value); { |
| + end, _, err := cssSpan(value, i) |
| + if err != nil { |
| + return out |
| + } |
| + if strings.EqualFold(value[i:end], "var") && end < len(value) && value[end] == '(' { |
| + close, err := braceMatchEnd(value, end+1) |
| + if err != nil { |
| + return out |
| } |
| + out = append(out, readVarRef(strings.TrimLeft(value[end+1:close], " \t\n\r\f"))) |
| + // Read on inside the brackets: a fallback can hold a var() |
| + // of its own. |
| + end++ |
| + } |
| + i = end |
| + } |
| + return out |
| +} |
| + |
| +// readVarRef reads what is inside one var(): the name it starts with, |
| +// and whether a comma outside any bracket or string follows. |
| +func readVarRef(inner string) varRef { |
| + var ref varRef |
| + depth := 0 |
| + for j := 0; j < len(inner); { |
| + end, _, err := cssSpan(inner, j) |
| + if err != nil { |
| + break |
| + } |
| + if j == 0 { |
| + ref.name = inner[:end] |
| } |
| + switch inner[j] { |
| + case '(', '[': |
| + depth++ |
| + case ')', ']': |
| + depth-- |
| + case ',': |
| + ref.fallback = ref.fallback || depth == 0 |
| + } |
| + j = end |
| } |
| + return ref |
| } |
| // Same drift check again, for the form family: field, field-select, |
| @@ -790,16 +880,22 @@ func TestModalPanelIsARenderedOpenDialog(t *testing.T) { |
| // Comments stripped first: this file explains ::backdrop and names |
| // the reset rule in prose, and a check that cannot tell a selector |
| // from a sentence about one is not a check. |
| + // Read as rules: a check that cannot tell a selector from a |
| + // sentence about one, or from a string, is not a check. |
| css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| - i := strings.Index(css, "dialog[rst-modal-panel]") |
| - if i < 0 { |
| + var reset *leafRule |
| + for _, r := range leafRules(t, css) { |
| + if slices.ContainsFunc(splitSelectorList(r.selector), func(s string) bool { return strings.Contains(s, "dialog[rst-modal-panel]") }) { |
| + reset = &r |
| + break |
| + } |
| + } |
| + if reset == nil { |
| t.Fatal("tokens.css has no scoped dialog reset; the UA dialog block would position the panel absolutely") |
| } |
| - rule := css[i:] |
| - rule = rule[:strings.Index(rule, "}")] |
| - for _, want := range []string{"position: static", "padding: 0", "margin: 0", "color: inherit"} { |
| - if !strings.Contains(rule, want) { |
| - t.Errorf("the dialog reset does not undo the UA block's %q: %s", want, rule) |
| + for _, want := range [][2]string{{"position", "static"}, {"padding", "0"}, {"margin", "0"}, {"color", "inherit"}} { |
| + if !reset.sets(want[0], want[1]) { |
| + t.Errorf("the dialog reset does not undo the UA block's %s: %s: %s { %v }", want[0], want[1], reset.selector, reset.decls) |
| } |
| } |
| // ::backdrop belongs to the top layer, which a rendered-open dialog |
| @@ -813,8 +909,8 @@ func TestModalPanelIsARenderedOpenDialog(t *testing.T) { |
| // The focus ring covers the whole app column, so a hand-rolled control |
| // inside [rst-page] no longer restates the outline. |
| func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { |
| - css := string(fiesta.TokensCSS()) |
| - if !strings.Contains(css, ":where([rst-page],") { |
| + sels := ruleSelectors(t, string(fiesta.TokensCSS())) |
| + if !slices.ContainsFunc(sels, func(s string) bool { return strings.HasPrefix(s, ":where([rst-page],") }) { |
| t.Error("tokens.css :focus-visible scope does not start with [rst-page]") |
| } |
| } |
| @@ -826,13 +922,24 @@ func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { |
| // hidden <select> nearly shipped at full width — the utility must come |
| // last, and this stops it drifting back. |
| func TestSrOnlyUtilityComesAfterTheRulesItMustBeat(t *testing.T) { |
| - css := string(fiesta.TokensCSS()) |
| - srOnly := strings.Index(css, ".rst-sr-only {") |
| + // Source order is read off the rules, so a selector mentioned in a |
| + // comment or a string earlier in the file is not taken for the |
| + // rule. |
| + rules := leafRules(t, string(fiesta.TokensCSS())) |
| + first := func(sel string) int { |
| + for i, r := range rules { |
| + if slices.Contains(splitSelectorList(r.selector), sel) { |
| + return i |
| + } |
| + } |
| + return -1 |
| + } |
| + srOnly := first(".rst-sr-only") |
| if srOnly < 0 { |
| t.Fatal("tokens.css no longer defines .rst-sr-only") |
| } |
| - for _, competitor := range []string{"[rst-input] {", "[rst-btn] {"} { |
| - at := strings.Index(css, competitor) |
| + for _, competitor := range []string{"[rst-input]", "[rst-btn]"} { |
| + at := first(competitor) |
| if at < 0 { |
| continue // that component may have been renamed; not this test's business |
| } |
| @@ -1163,16 +1270,18 @@ func TestTokensCSSHasNoColourLiterals(t *testing.T) { |
| // var(--rst-shadow-lift) and var(--rst-overlay) declared per theme — |
| // so this gate also matches rgba()/rgb()/hsl() function values, not |
| // just bare hex, to keep that claim enforced instead of just stated. |
| - decl := regexp.MustCompile(`:\s*[^;]*(#[0-9a-fA-F]{3,6}|rgba?\(|hsl\()`) |
| + literal := regexp.MustCompile(`#[0-9a-fA-F]{3,6}|rgba?\(|hsl\(`) |
| // Ink and light: tokens.css may paint pure black or pure white |
| // at an alpha, where the strength is a number token or a fixed |
| // fraction. Those two spellings are the only literals allowed; a |
| - // line that mixes one with any other colour function still fails. |
| + // value that mixes one with any other colour function still fails. |
| achromatic := regexp.MustCompile(`rgb\((0 0 0|255 255 255) / `) |
| - for i, line := range strings.Split(string(fiesta.TokensCSS()), "\n") { |
| - stripped := achromatic.ReplaceAllString(line, "ink(") |
| - if (strings.Contains(stripped, "#") || strings.Contains(stripped, "rgb") || strings.Contains(stripped, "hsl")) && decl.MatchString(stripped) { |
| - t.Errorf("tokens.css line %d declares a colour literal: %s", i+1, strings.TrimSpace(line)) |
| + // Every declaration value, read with the declaration reader: a |
| + // value written over two lines was missed by a line-by-line read. |
| + decls, _ := parseDeclarations(t, "tokens.css", string(fiesta.TokensCSS())) |
| + for _, d := range decls { |
| + if literal.MatchString(achromatic.ReplaceAllString(d.Value, "ink(")) { |
| + t.Errorf("tokens.css declares a colour literal: %s", d) |
| } |
| } |
| } |
| @@ -1195,9 +1304,12 @@ func TestErrorPageClassesAreStyled(t *testing.T) { |
| // [hidden] rule, and the passkey door would show before its script |
| // decided it can work. |
| func TestHiddenButtonsStayHidden(t *testing.T) { |
| - if !strings.Contains(string(fiesta.TokensCSS()), `.rst-btn[hidden], [rst-btn][hidden] { display: none; }`) { |
| - t.Fatal("tokens.css does not keep a [hidden] button hidden") |
| + for _, r := range leafRules(t, string(fiesta.TokensCSS())) { |
| + if r.context == "" && r.selector == ".rst-btn[hidden], [rst-btn][hidden]" && r.sets("display", "none") { |
| + return |
| + } |
| } |
| + t.Fatal("tokens.css does not keep a [hidden] button hidden") |
| } |
| // The depth axis is a theme decision: day turns it on, and plain and |
| @@ -1264,10 +1376,11 @@ func TestDayTurnsDepthOnAndTheOthersStayFlat(t *testing.T) { |
| // printout does not need it. The stylesheet is the only place this can |
| // happen, because the art carries no style of its own. |
| func TestStageArtIsHiddenUnderForcedColoursAndInPrint(t *testing.T) { |
| - css := string(fiesta.TokensCSS()) |
| + rules := leafRules(t, string(fiesta.TokensCSS())) |
| for _, media := range []string{"@media (forced-colors: active)", "@media print"} { |
| - re := regexp.MustCompile(regexp.QuoteMeta(media) + ` \{\s*\.rst-stage-art, \[rst-stage-art\] \{ display: none; \}\s*\}`) |
| - if !re.MatchString(css) { |
| + if !slices.ContainsFunc(rules, func(r leafRule) bool { |
| + return r.context == media && r.selector == ".rst-stage-art, [rst-stage-art]" && r.sets("display", "none") |
| + }) { |
| t.Errorf("tokens.css does not hide [rst-stage-art] under %s", media) |
| } |
| } |
diff --git a/check/depth_media_test.go b/check/depth_media_test.go| index a613c5d..b3e4d45 100644 |
| --- a/check/depth_media_test.go |
| +++ b/check/depth_media_test.go |
| @@ -1,6 +1,7 @@ |
| package check |
| import ( |
| + "slices" |
| "strings" |
| "testing" |
| @@ -24,43 +25,92 @@ func TestForcedColoursAndPrintFlattenTheAxisAndCoverEveryComponent(t *testing.T) |
| "--rst-press-wash: 0", "--rst-press-spot: 0", "--rst-press-wash-plain: 0", "--rst-press-spot-plain: 0", |
| "--rst-tint-hover: var(--rst-surface)", "--rst-tint-press: var(--rst-surface)", "--rst-tint-tap: transparent", "--rst-row-tint-hover: var(--rst-surface)", "--rst-row-tint-press: var(--rst-surface)", "--rst-focus-halo: transparent", |
| "--rst-summary-edge: transparent", "--rst-summary-edge-hover: transparent"} |
| - printEnd := 0 |
| + forced := append(flat, "html:root {", "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", |
| + "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "[rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a[aria-current]", "[rst-dropdown-menu] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-seg-tabs] a[aria-current]", `[rst-seg-tabs] > button[aria-pressed="true"]`, "[rst-side-nav] a[aria-current]", |
| + `[rst-shell-sidebar][rst-shell-sidebar~="index"] [rst-shell-nav] > a[aria-current]:hover`, `[rst-shell-console][rst-shell-console~="index"] [rst-shell-nav] > a[aria-current]:active`, |
| + "ButtonBorder", "CanvasText", "ButtonText", "Highlight", "HighlightText", "box-shadow: none") |
| + print := append(flat, "html:root {", "--rst-accent-fill: transparent", "--rst-accent-fill-hover: transparent", "--rst-on-accent-fill: var(--rst-text)", "--rst-danger-fill: transparent", "--rst-on-danger-fill-hover: var(--rst-text)", "--rst-danger-hover-filter: none", "--rst-control-face-hover: transparent", "--rst-control-text-hover: var(--rst-text)", "--rst-knob: var(--rst-text)", |
| + "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", "[rst-switch]:has(input:checked) [rst-switch-track]", "[rst-tblock] > [rst-tblock-head]:has(> input:checked) > [rst-switch-track]", "[rst-seg-tabs] a[aria-current]", `[rst-seg-tabs] > button[aria-pressed="true"]`, "[rst-side-nav] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-dropdown-menu]", "[rst-row-menu-panel]", |
| + "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "var(--rst-line-strong)", "background-image: none", "box-shadow: none", "outline: 1px solid var(--rst-line-strong)") |
| + // The blocks are read as rules, the last top-level block of each |
| + // query being the one at the end of the file. A want is a rule's |
| + // selector when it ends in " {", a declaration when it holds ": ", |
| + // a selector a rule names when it starts with [ or ., a var() a value |
| + // reads, and otherwise a word a value holds. Read as text, any of them was satisfied by a |
| + // comment, and the block was cut by counting braces. |
| + rules := leafRules(t, css) |
| + printTop := -1 |
| for _, block := range []struct { |
| - header string |
| - want []string |
| + context string |
| + want []string |
| }{ |
| - {"@media (forced-colors: active) {", append(flat, "html:root {", "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", |
| - "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "[rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a[aria-current]", "[rst-dropdown-menu] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-seg-tabs] a[aria-current]", `[rst-seg-tabs] > button[aria-pressed="true"]`, "[rst-side-nav] a[aria-current]", |
| - `[rst-shell-sidebar][rst-shell-sidebar~="index"] [rst-shell-nav] > a[aria-current]:hover`, `[rst-shell-console][rst-shell-console~="index"] [rst-shell-nav] > a[aria-current]:active`, |
| - "ButtonBorder", "CanvasText", "ButtonText", "Highlight", "HighlightText", "box-shadow: none")}, |
| - {"@media print {", append(flat, "html:root {", "--rst-accent-fill: transparent", "--rst-accent-fill-hover: transparent", "--rst-on-accent-fill: var(--rst-text)", "--rst-danger-fill: transparent", "--rst-on-danger-fill-hover: var(--rst-text)", "--rst-danger-hover-filter: none", "--rst-control-face-hover: transparent", "--rst-control-text-hover: var(--rst-text)", "--rst-knob: var(--rst-text)", |
| - "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", "[rst-switch]:has(input:checked) [rst-switch-track]", "[rst-tblock] > [rst-tblock-head]:has(> input:checked) > [rst-switch-track]", "[rst-seg-tabs] a[aria-current]", `[rst-seg-tabs] > button[aria-pressed="true"]`, "[rst-side-nav] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-dropdown-menu]", "[rst-row-menu-panel]", |
| - "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "var(--rst-line-strong)", "background-image: none", "box-shadow: none", "outline: 1px solid var(--rst-line-strong)")}, |
| + {"@media (forced-colors: active)", forced}, |
| + {"@media print", print}, |
| } { |
| - i := strings.LastIndex(css, block.header) |
| - if i < 0 { |
| - t.Fatalf("tokens.css has no %s block at the end of the file", block.header) |
| + last := -1 |
| + for _, r := range rules { |
| + if r.context == block.context { |
| + last = max(last, r.top) |
| + } |
| + } |
| + if last < 0 { |
| + t.Fatalf("tokens.css has no %s block at the end of the file", block.context) |
| } |
| - body := blockBody(t, "tokens.css", css, block.header, i) |
| - if block.header == "@media print {" { |
| - printEnd = i + len(block.header) + len(body) + 1 |
| + if block.context == "@media print" { |
| + printTop = last |
| + } |
| + var in []leafRule |
| + for _, r := range rules { |
| + if r.top == last { |
| + in = append(in, r) |
| + } |
| } |
| for _, w := range block.want { |
| - if !strings.Contains(body, w) { |
| - t.Errorf("the %s block does not carry %q", block.header, w) |
| + if !blockCarries(in, w) { |
| + t.Errorf("the %s block does not carry %q", block.context, w) |
| } |
| } |
| - if strings.Contains(body, "!important") { |
| - t.Errorf("the %s block uses !important (C11)", block.header) |
| + for _, r := range in { |
| + for _, d := range r.decls { |
| + if d[2] != "" { |
| + t.Errorf("the %s block uses !important (C11)", block.context) |
| + } |
| + } |
| } |
| } |
| // Nothing that paints a button, a card or a nav item may follow the |
| - // print block: its rules win by order. The search starts after the |
| - // block's closing brace, because the block names those selectors |
| - // itself. |
| + // print block: its rules win by order. |
| for _, sel := range []string{`[rst-btn~="primary"]`, `[rst-shell-nav] a[aria-current]`, `[rst-box]`} { |
| - if strings.Contains(css[printEnd:], sel+" {") || strings.Contains(css[printEnd:], sel+",") { |
| - t.Errorf("%q is styled after the print block; the print rules rely on source order", sel) |
| + for _, r := range rules { |
| + if r.top > printTop && slices.ContainsFunc(splitSelectorList(r.selector), func(s string) bool { return strings.Contains(s, sel) }) { |
| + t.Errorf("%q is styled after the print block, by %s; the print rules rely on source order", sel, r.selector) |
| + } |
| } |
| } |
| } |
| + |
| +// blockCarries reports whether the rules of one block carry want, read |
| +// as TestForcedColoursAndPrintFlattenTheAxisAndCoverEveryComponent |
| +// says. |
| +func blockCarries(rules []leafRule, want string) bool { |
| + return slices.ContainsFunc(rules, func(r leafRule) bool { |
| + sels := splitSelectorList(r.selector) |
| + switch { |
| + case strings.HasSuffix(want, " {"): |
| + return slices.Contains(sels, strings.TrimSuffix(want, " {")) |
| + case strings.Contains(want, ": "): |
| + prop, value, _ := strings.Cut(want, ": ") |
| + return slices.Contains(r.decls, [3]string{prop, value, ""}) |
| + case strings.HasPrefix(want, "[") || strings.HasPrefix(want, "."): |
| + return slices.ContainsFunc(sels, func(s string) bool { return strings.Contains(s, want) }) |
| + case strings.HasPrefix(want, "var("): |
| + name := strings.TrimSuffix(strings.TrimPrefix(want, "var("), ")") |
| + return slices.ContainsFunc(r.decls, func(d [3]string) bool { |
| + return slices.ContainsFunc(varRefs(d[1]), func(ref varRef) bool { return ref.name == name }) |
| + }) |
| + } |
| + return slices.ContainsFunc(r.decls, func(d [3]string) bool { |
| + return slices.Contains(strings.FieldsFunc(d[1], func(c rune) bool { return strings.ContainsRune(" ,()/", c) }), want) |
| + }) |
| + }) |
| +} |
diff --git a/check/header_rule_test.go b/check/header_rule_test.go| index 5c81130..cbc7f68 100644 |
| --- a/check/header_rule_test.go |
| +++ b/check/header_rule_test.go |
| @@ -2,6 +2,7 @@ package check |
| import ( |
| "regexp" |
| + "slices" |
| "strings" |
| "testing" |
| @@ -27,24 +28,36 @@ func TestTheRakeLineIsDeclaredRetiredExactlyOnce(t *testing.T) { |
| // The rule as it now stands, spelled in both markup vocabularies |
| // because tokens.css pairs a class selector with every attribute one |
| // until stage 3 of the migration. |
| - const retired = ".rst-page-header::after, [rst-page-header]::after {\n content: none;\n}" |
| - css := string(fiesta.TokensCSS()) |
| - if got := strings.Count(css, retired); got != 1 { |
| - t.Errorf("tokens.css declares the retired header pseudo-element %d times as\n%s\nwant exactly 1 — the whole point of retiring it in one file is that there is one place to look", got, retired) |
| + // |
| + // Read as rules, not text: a copy in a comment counted as the one |
| + // declaration, and a pattern that stopped at a comma missed a |
| + // pseudo-element after a selector list inside :is(). |
| + rules := leafRules(t, string(fiesta.TokensCSS())) |
| + const retired = ".rst-page-header::after, [rst-page-header]::after" |
| + got := 0 |
| + for _, r := range rules { |
| + if r.selector == retired && slices.Equal(r.decls, [][3]string{{"content", "none", ""}}) { |
| + got++ |
| + } |
| + } |
| + if got != 1 { |
| + t.Errorf("tokens.css declares the retired header pseudo-element %d times as\n%s { content: none; }\nwant exactly 1 — the whole point of retiring it in one file is that there is one place to look", got, retired) |
| } |
| - // Nothing else may draw one. The pattern is deliberately loose (any |
| - // selector naming a page header, any pseudo-element on it) so a |
| - // flourish reintroduced under ::before, or on the titles, is caught |
| - // as readily as the exact rule that was removed. It stops at a comma |
| - // so a selector LIST is read as the selectors it is, not as one long |
| - // string that happens to start and end in the right places. |
| - headerPseudo := regexp.MustCompile(`(\.rst-page-header|\[rst-page-header)[^{;,]*::(after|before)`) |
| - for _, m := range headerPseudo.FindAllString(css, -1) { |
| - if strings.TrimSpace(m) == ".rst-page-header::after" || strings.TrimSpace(m) == "[rst-page-header]::after" { |
| + // Nothing else may draw one. The reading is deliberately loose (any |
| + // selector naming a page header, any pseudo-element after it, : or |
| + // ::) so a flourish reintroduced under ::before, or on the titles, |
| + // is caught as readily as the exact rule that was removed. |
| + headerPseudo := regexp.MustCompile(`(\.rst-page-header|\[rst-page-header).*::?(after|before)`) |
| + for _, r := range rules { |
| + if r.selector == retired { |
| continue |
| } |
| - t.Errorf("tokens.css draws %q on the page header; the header's one decoration is its rule, and that lives on the theme axis as --rst-header-rule", m) |
| + for _, sel := range splitSelectorList(r.selector) { |
| + if headerPseudo.MatchString(sel) { |
| + t.Errorf("tokens.css draws %q on the page header; the header's one decoration is its rule, and that lives on the theme axis as --rst-header-rule", sel) |
| + } |
| + } |
| } |
| for _, name := range fiesta.ThemeNames() { |
| theme, ok := fiesta.ThemeCSS(name) |
diff --git a/check/markup_v3_browser_test.go b/check/markup_v3_browser_test.go| index e7cec29..06a8f69 100644 |
| --- a/check/markup_v3_browser_test.go |
| +++ b/check/markup_v3_browser_test.go |
| @@ -108,11 +108,16 @@ func bareRSTMarkup(markup string) string { |
| // remembering to add it here. |
| func styleProperties(t testing.TB) []string { |
| t.Helper() |
| - css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| + // Read as declarations: a pattern over the text found property |
| + // names inside strings, and missed one written in capitals. |
| + decls, err := declarationsOf("tokens.css", string(fiesta.TokensCSS())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| seen := map[string]bool{} |
| - for _, m := range regexp.MustCompile(`[;{]\s*([a-z-]+)\s*:`).FindAllStringSubmatch(css, -1) { |
| - if name := m[1]; !strings.HasPrefix(name, "--") { |
| - seen[name] = true |
| + for _, d := range decls { |
| + if !strings.HasPrefix(d.Property, "--") { |
| + seen[d.Property] = true |
| } |
| } |
| for _, name := range []string{ |
diff --git a/check/scripts_test.go b/check/scripts_test.go| index 67be589..9e36305 100644 |
| --- a/check/scripts_test.go |
| +++ b/check/scripts_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| + "slices" |
| "strings" |
| "testing" |
| @@ -599,7 +600,7 @@ func TestAnchorContract(t *testing.T) { |
| if test < 0 || listen < 0 || test > listen { |
| t.Error("anchor.js must test for anchor positioning, and return, before it listens for anything") |
| } |
| - if !strings.Contains(js, "is-flip-inline") || !strings.Contains(string(fiesta.TokensCSS()), "[rst-dropdown].is-flip-inline > [rst-dropdown-menu]") { |
| + if !strings.Contains(js, "is-flip-inline") || !slices.Contains(ruleSelectors(t, string(fiesta.TokensCSS())), "[rst-dropdown].is-flip-inline > [rst-dropdown-menu]") { |
| t.Error("anchor.js and tokens.css no longer agree on .is-flip-inline") |
| } |
| if n := len(js); n > 4*1024 { |
diff --git a/check/sizing_test.go b/check/sizing_test.go| index 4bc6e03..2ecb495 100644 |
| --- a/check/sizing_test.go |
| +++ b/check/sizing_test.go |
| @@ -11,23 +11,18 @@ import ( |
| "amadan.net/rastrillo/fiesta/example" |
| ) |
| -// The text half of the touch-size gates: the zoom floors, the type step, |
| -// the target floor and the tap inventory, read off tokens.css as text. |
| -// The browser half, which measures what the floors produce, reads the |
| -// same inventory from the drives. |
| +// The stylesheet half of the touch-size gates: the zoom floors, the |
| +// type step, the target floor and the tap inventory, read off |
| +// tokens.css with the declaration reader. The browser half, which |
| +// measures what the floors produce, reads the same inventory from the |
| +// drives. |
| -// touchQuery is the one media query every touch-only rule sits in (§1.1 |
| -// of the mobile ergonomics design spec). Written once so a typo in one |
| -// floor cannot quietly scope it to a query nothing matches. |
| -const touchQuery = "@media (pointer: coarse), (max-width: 40rem) {" |
| - |
| -// touchContext is the touch query as a rule's context names it. |
| +// touchContext is the one media query every touch-only rule sits in |
| +// (§1.1 of the mobile ergonomics design spec), as a rule's context |
| +// names it. Written once so a typo in one floor cannot quietly scope |
| +// it to a query nothing matches. |
| const touchContext = "@media (pointer: coarse), (max-width: 40rem)" |
| -// zoomFloorRule finds a floor: the touch query holding exactly one rule |
| -// whose only declaration is the floor. Read off comment-stripped CSS. |
| -var zoomFloorRule = regexp.MustCompile(`@media \(pointer: coarse\), \(max-width: 40rem\) \{\s*([^{}]+?)\s*\{\s*font-size: max\(1rem, 1em\);\s*\}\s*\}`) |
| - |
| // bareZoomFloor is the zero-weight floor for an app's own inputs: an |
| // input with no rst- attribute is sized by the UA stylesheet, which any |
| // author rule beats, so :where() still lifts it and any app rule that |
| @@ -43,14 +38,26 @@ const bareZoomFloor = ":where(input:not([type=checkbox]):not([type=radio]):not([ |
| // this rewrite exists to remove). Moving any floor one rule up or down |
| // fails here. |
| func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| + // Read as rules, each knowing the top-level statement it sits in: |
| + // a floor is a touch query holding exactly one rule, whose only |
| + // declaration is font-size: max(1rem, 1em), and "directly after" |
| + // is the next statement. Read as text, the rule a floor follows |
| + // ended at the first }, a quoted one included. |
| css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| - floors := map[string][2]int{} |
| - for _, m := range zoomFloorRule.FindAllStringSubmatchIndex(css, -1) { |
| - sel := collapseSpace(css[m[2]:m[3]]) |
| - if _, dup := floors[sel]; dup { |
| - t.Errorf("two zoom floors for %q", sel) |
| + rules := leafRules(t, css) |
| + byTop := map[int][]leafRule{} |
| + for _, r := range rules { |
| + byTop[r.top] = append(byTop[r.top], r) |
| + } |
| + floors := map[string]int{} |
| + for top, in := range byTop { |
| + if len(in) != 1 || in[0].context != touchContext || !slices.Equal(in[0].decls, [][3]string{{"font-size", "max(1rem, 1em)", ""}}) { |
| + continue |
| + } |
| + if _, dup := floors[in[0].selector]; dup { |
| + t.Errorf("two zoom floors for %q", in[0].selector) |
| } |
| - floors[sel] = [2]int{m[0], m[1]} |
| + floors[in[0].selector] = top |
| } |
| if len(floors) != 4 { |
| t.Errorf("tokens.css carries %d zoom floors, want 4 (bare elements, input, textarea, search): %v", len(floors), floors) |
| @@ -61,13 +68,14 @@ func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| if !strings.HasPrefix(bareZoomFloor, ":where(") || !strings.HasSuffix(bareZoomFloor, ")") || specificity(bareZoomFloor) != [3]int{} { |
| t.Errorf("the bare-element floor is not wholly inside :where(): %v", specificity(bareZoomFloor)) |
| } |
| - base := func(prelude string) (start, end int) { |
| - loc := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(prelude) + ` \{`).FindStringIndex(css) |
| - if loc == nil { |
| - t.Fatalf("tokens.css has no top-level rule %q", prelude) |
| + base := func(prelude string) int { |
| + for _, r := range rules { |
| + if r.context == "" && r.selector == prelude { |
| + return r.top |
| + } |
| } |
| - close := strings.IndexByte(css[loc[1]:], '}') |
| - return loc[0], loc[1] + close + 1 |
| + t.Fatalf("tokens.css has no top-level rule %q", prelude) |
| + return 0 |
| } |
| for _, c := range []struct{ floor, after, before string }{ |
| {".rst-input, [rst-input]", ".rst-input, [rst-input]", `.rst-input--primary, [rst-input~="primary"]`}, |
| @@ -79,23 +87,27 @@ func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| t.Errorf("no zoom floor for %q", c.floor) |
| continue |
| } |
| - _, end := base(c.after) |
| - // A floor above its reset is the dead-floor case itself; slicing |
| - // the gap backwards would panic instead of naming it. |
| - if at[0] < end { |
| + switch after := base(c.after); { |
| + case at < after: |
| 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) |
| - } else if gap := strings.TrimSpace(css[end:at[0]]); gap != "" { |
| - t.Errorf("the floor for %q is not directly after the rule that resets its font; between them:\n%.200s", c.floor, gap) |
| - } |
| - if c.before != "" { |
| - if start, _ := base(c.before); at[1] > start { |
| - t.Errorf("the floor for %q comes after %q, so it overrides the field that is big on purpose", c.floor, c.before) |
| + case at > after+1: |
| + var between []string |
| + for top := after + 1; top < at; top++ { |
| + for _, r := range byTop[top] { |
| + between = append(between, r.selector) |
| + } |
| } |
| + t.Errorf("the floor for %q is not directly after the rule that resets its font; between them: %q", c.floor, between) |
| + } |
| + if c.before != "" && at > base(c.before) { |
| + t.Errorf("the floor for %q comes after %q, so it overrides the field that is big on purpose", c.floor, c.before) |
| } |
| } |
| // The block this replaces: one pointer-only query sizing the |
| // components at a weight that beat the primary field. |
| - if strings.Contains(css, "@media (pointer: coarse) {") { |
| + if slices.ContainsFunc(rules, func(r leafRule) bool { |
| + return strings.Contains(r.context, "@media (pointer: coarse)") && r.context != touchContext |
| + }) { |
| t.Error("tokens.css still has a pointer-only query; every touch rule uses the one query of §1.1") |
| } |
| } |
| @@ -103,30 +115,34 @@ func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| // The type step itself (§1.2), read as text so a desktop-only edit |
| // cannot move it: the four tokens, in the touch query, at :root. |
| func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) { |
| - css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| - re := regexp.MustCompile(regexp.QuoteMeta(touchQuery) + `\s*:root\s*\{([^{}]*)\}`) |
| - m := re.FindStringSubmatch(css) |
| - if m == nil { |
| + // Read as declarations: a token or a rule in a comment or a string |
| + // is not one, and a rule's body ended at its first }, quoted or not. |
| + css := string(fiesta.TokensCSS()) |
| + decls, _ := parseDeclarations(t, "tokens.css", css) |
| + touch := declaredAt(decls, touchContext, ":root") |
| + if len(touch) == 0 { |
| t.Fatal("no :root block inside the touch query") |
| } |
| - for _, want := range []string{"--rst-fs-lg: 1.1875rem;", "--rst-fs-base: 1rem;", "--rst-fs-sm: 0.875rem;", "--rst-fs-xs: 0.8125rem;"} { |
| - if !strings.Contains(m[1], want) { |
| - t.Errorf("the touch :root block lacks %q:\n%s", want, m[1]) |
| + for _, want := range [][2]string{{"--rst-fs-lg", "1.1875rem"}, {"--rst-fs-base", "1rem"}, {"--rst-fs-sm", "0.875rem"}, {"--rst-fs-xs", "0.8125rem"}} { |
| + if touch[want[0]] != want[1] { |
| + t.Errorf("the touch :root block sets %s to %q, want %s", want[0], touch[want[0]], want[1]) |
| } |
| } |
| - if !regexp.MustCompile(`--rst-tap:\s*2\.75rem;`).MatchString(css) { |
| + if !slices.ContainsFunc(decls, func(d declaration) bool { return d.Property == "--rst-tap" && fullValue(d) == "2.75rem" }) { |
| t.Error("--rst-tap: 2.75rem is not declared") |
| } |
| // The two components that spelled the xs step as a literal paint |
| // with the token now, or they would stay 11.5px on a phone. |
| + rules := leafRules(t, css) |
| for _, sel := range []string{".rst-lrow--head, [rst-lrow~=\"head\"]", ".rst-tip::after, [rst-tip]::after"} { |
| - loc := strings.Index(css, sel+" {") |
| - if loc < 0 { |
| + i := slices.IndexFunc(rules, func(r leafRule) bool { return r.selector == sel }) |
| + if i < 0 { |
| t.Fatalf("no rule %q", sel) |
| } |
| - body := css[loc : loc+strings.IndexByte(css[loc:], '}')] |
| - if strings.Contains(body, "0.71875rem") || !strings.Contains(body, "font-size: var(--rst-fs-xs)") { |
| - t.Errorf("%s does not paint with var(--rst-fs-xs): %s", sel, body) |
| + r := rules[i] |
| + literal := slices.ContainsFunc(r.decls, func(d [3]string) bool { return strings.Contains(d[1], "0.71875rem") }) |
| + if literal || !r.sets("font-size", "var(--rst-fs-xs)") { |
| + t.Errorf("%s does not paint with var(--rst-fs-xs): %v", sel, r.decls) |
| } |
| } |
| } |
| @@ -138,20 +154,24 @@ func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) { |
| // --rst-tap directly would be 44px on a desktop too, or, moved out of |
| // the touch query, the 32px desktop floor would never reach it. |
| func TestTheTargetFloorIsOneToken(t *testing.T) { |
| - css := stripCSSComments(t, string(fiesta.TokensCSS())) |
| - root := regexp.MustCompile(`(?m)^:root \{([^{}]*)\}`).FindStringSubmatch(css) |
| - if root == nil || !regexp.MustCompile(`--rst-target:\s*2rem;`).MatchString(root[1]) { |
| + // Read as declarations, where the cascade reads them: a :root |
| + // block found by a pattern ended at its first }, quoted or not. |
| + css := string(fiesta.TokensCSS()) |
| + decls, _ := parseDeclarations(t, "tokens.css", css) |
| + if declaredAt(decls, "", ":root")["--rst-target"] != "2rem" { |
| t.Error("the top-level :root does not declare --rst-target: 2rem, the desktop floor") |
| } |
| - raised := false |
| - for _, m := range regexp.MustCompile(regexp.QuoteMeta(touchQuery)+`\s*:root\s*\{([^{}]*)\}`).FindAllStringSubmatch(css, -1) { |
| - raised = raised || regexp.MustCompile(`--rst-target:\s*var\(--rst-tap\);`).MatchString(m[1]) |
| - } |
| - if !raised { |
| + if declaredAt(decls, touchContext, ":root")["--rst-target"] != "var(--rst-tap)" { |
| t.Error("no :root block in the touch query raises --rst-target to var(--rst-tap)") |
| } |
| - if m := regexp.MustCompile(`(?:min-)?(?:block|inline)-size:\s*var\(--rst-tap\)`).FindString(css); m != "" { |
| - t.Errorf("a rule sizes a control with %q; floors read --rst-target, so the desktop gets its own floor", m) |
| + sizes := []string{"min-block-size", "min-inline-size", "block-size", "inline-size"} |
| + // A size whose value is var(--rst-tap) itself; the calendar's |
| + // grid is seven taps wide, which is a width, not a floor. |
| + for _, d := range decls { |
| + refs := varRefs(d.Value) |
| + if slices.Contains(sizes, d.Property) && strings.HasPrefix(d.Value, "var(") && len(refs) > 0 && refs[0].name == "--rst-tap" { |
| + t.Errorf("%s sizes a control with --rst-tap; floors read --rst-target, so the desktop gets its own floor", d) |
| + } |
| } |
| // Each control in the inventory has its floor, read through the |
| // token, outside the touch query: a floor only the touch query |
| @@ -165,7 +185,7 @@ func TestTheTargetFloorIsOneToken(t *testing.T) { |
| } |
| } |
| sized := func(r leafRule) bool { |
| - for _, p := range []string{"min-block-size", "min-inline-size", "block-size", "inline-size"} { |
| + for _, p := range sizes { |
| if r.sets(p, "var(--rst-target)") { |
| return true |
| } |
diff --git a/check/spelling_test.go b/check/spelling_test.go| index f41417d..7c67421 100644 |
| --- a/check/spelling_test.go |
| +++ b/check/spelling_test.go |
| @@ -127,6 +127,18 @@ func cssRules(t testing.TB, css string) []cssRule { |
| return rules |
| } |
| +// ruleSelectors is every selector of every rule in css, read with |
| +// cssRules: a gate that asks whether a selector is in the file asks |
| +// it here, where prose and strings are not selectors. |
| +func ruleSelectors(t testing.TB, css string) []string { |
| + t.Helper() |
| + var out []string |
| + for _, rule := range cssRules(t, css) { |
| + out = append(out, rule.selectors...) |
| + } |
| + return out |
| +} |
| + |
| // splitSelectorList splits on the commas that separate selectors, |
| // leaving alone the ones inside :is(), :where(), :not(), :has() and |
| // attribute values. It reads with cssSpan, so a comma or a bracket |