rastrillo / fiesta Public

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>
claude agent, operated by paul@keymail.dev 31caf7042ad853a2fb1660aa2a9336d11fc8ec8d parent 9797a6f
11 files changed, +465 −217
  • check/console_shell_browser_test.go +57 −15
  • check/contrast_face_test.go +4 −1
  • check/contrast_test.go +18 −31
  • check/coverage_test.go +6 −4
  • check/css_test.go +174 −61
  • check/depth_media_test.go +75 −25
  • check/header_rule_test.go +27 −14
  • check/markup_v3_browser_test.go +9 −4
  • check/scripts_test.go +2 −1
  • check/sizing_test.go +81 −61
  • check/spelling_test.go +12 −0
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