rastrillo / fiesta Public

Read the motion gate's declarations with the declaration reader

The motion gate found its declarations with a pattern over rule
bodies, which knows nothing of strings. In
.moving { --rst-proof: "; transition: none;"; } inside the reduce
block, it found transition: none in the string and counted .moving as
disabled, though a browser sees only a custom property. It cut its
reduce blocks with braceMatchEnd as well, so quoted braces in two
rules could carry a later rule into the block; that is fixed in the
bracket reader, and the gate no longer cuts blocks at all.

It now reads the whole stylesheet with declarationsOf. A disable is a
transition or animation declared none in a rule whose context holds
the reduce query; everything else declared outside one is checked
against them, selector by selector. Selectors come from
splitSelectorList, which keeps a comma inside :has() in its selector;
the old comma split cut two :has() rules in tokens.css in half, which
is why the gate now checks 34 declarations there instead of 36.

cssDeclarations reads a property name in lower case, as a browser
does, unless it is a custom property: TRANSITION: opacity 1s escaped
the case-sensitive pattern. None of the 26 stylesheets changes.

Red: TestTheMotionGateHasNoKnownHoles, on the pattern and the old
braceMatchEnd, passes both of Astra's stylesheets. Mutations, each
red and reverted: a reduce rule counted as disabling when any value
holds "transition: none" (M7), property names read as written (M8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 94c6b3e5a1cf9883c023008240e4ac3c9b7e676b parent d37c5b9
2 files changed, +93 −101
  • check/baseline_test.go +7 −1
  • check/css_test.go +86 −100
diff --git a/check/baseline_test.go b/check/baseline_test.go
index 6f077ca..dc6748b 100644
--- a/check/baseline_test.go
+++ b/check/baseline_test.go
@@ -375,6 +375,10 @@ func cssItems(src string) ([]cssItem, error) {
// is an error, where a count below zero read every declaration after
// it as part of the one before. A { in the block is a nested rule,
// which these files do not use and this reader does not read.
+//
+// A property other than a custom property is read in lower case, as a
+// browser reads it: TRANSITION is transition, and a gate looking for
+// one must not miss the other.
func cssDeclarations(block string) ([][3]string, error) {
var out [][3]string
start := 0
@@ -387,7 +391,9 @@ func cssDeclarations(block string) ([][3]string, error) {
if !ok {
return fmt.Errorf("%q is not a declaration", collapseSpace(piece))
}
- prop = strings.TrimSpace(prop)
+ if prop = strings.TrimSpace(prop); !strings.HasPrefix(prop, "--") {
+ prop = strings.ToLower(prop)
+ }
value = collapseSpace(value)
important := ""
if v, ok := cutImportant(value); ok {
diff --git a/check/css_test.go b/check/css_test.go
index b1dc1e8..6806067 100644
--- a/check/css_test.go
+++ b/check/css_test.go
@@ -4,6 +4,7 @@ import (
"fmt"
"reflect"
"regexp"
+ "slices"
"strconv"
"strings"
"testing"
@@ -318,35 +319,6 @@ func TestBothSchemesAreAuthoredInEveryTheme(t *testing.T) {
// as a way to silence the failure.
var reducedMotionAllowlist = map[string]bool{}
-// reducedMotionBlocks returns the raw contents of every
-// @media (prefers-reduced-motion: reduce) { ... } block in css, and rest
-// = css with those blocks (header, braces and all) removed — so a
-// transition-selector search over rest never mistakes a reduce block's
-// own "transition: none" for an active, undisabled transition.
-func reducedMotionBlocks(t testing.TB, css string) (blocks []string, rest string) {
- const header = "@media (prefers-reduced-motion: reduce) {"
- var b strings.Builder
- last, idx := 0, 0
- for {
- i := strings.Index(css[idx:], header)
- if i < 0 {
- break
- }
- blockStart := idx + i
- contentStart := blockStart + len(header)
- end, err := braceMatchEnd(css, contentStart)
- if err != nil {
- t.Fatal(err)
- }
- blocks = append(blocks, css[contentStart:end])
- b.WriteString(css[last:blockStart])
- last = end + 1
- idx = end + 1
- }
- b.WriteString(css[last:])
- return blocks, b.String()
-}
-
// leafRule is one selector plus its (brace-matched, non-nested)
// declaration body.
type leafRule struct{ selector, body string }
@@ -441,50 +413,6 @@ func selectorList(sel string) []string {
return out
}
-// motionDecl is one transition/animation declaration: the CSS property
-// name and its (trimmed) value.
-type motionDecl struct{ prop, val string }
-
-// motionPropertyPattern finds every transition/animation declaration in
-// a rule body and captures its value, so "transition: none" (the disable
-// declaration itself) can be told apart from an actual animated
-// property, and so a rule declaring both properties is not silently
-// reduced to only the first FindAll would otherwise stop at.
-var motionPropertyPattern = regexp.MustCompile(`(?:^|;)\s*(transition|animation)\s*:\s*([^;]+)`)
-
-func motionDecls(body string) []motionDecl {
- var out []motionDecl
- for _, m := range motionPropertyPattern.FindAllStringSubmatch(body, -1) {
- out = append(out, motionDecl{prop: m[1], val: strings.TrimSpace(m[2])})
- }
- return out
-}
-
-// reduceIndex maps an exact selector to the set of properties some
-// prefers-reduced-motion: reduce rule disables (declares "none") for
-// that exact selector.
-type reduceIndex map[string]map[string]bool
-
-func buildReduceIndex(t testing.TB, blocks []string) reduceIndex {
- idx := reduceIndex{}
- for _, block := range blocks {
- for _, rule := range leafRules(t, block) {
- for _, sel := range selectorList(rule.selector) {
- for _, d := range motionDecls(rule.body) {
- if d.val != "none" {
- continue
- }
- if idx[sel] == nil {
- idx[sel] = map[string]bool{}
- }
- idx[sel][d.prop] = true
- }
- }
- }
- }
- return idx
-}
-
// TestReducedMotionDisablesEveryTransition is the motion gate:
// every selector that declares a real transition or animation outside a
// prefers-reduced-motion block must have that exact selector — not a
@@ -510,6 +438,46 @@ func TestShellCSSReducedMotionDisablesEveryAnimation(t *testing.T) {
assertReducedMotion(t, "shell.css", string(fiesta.ShellCSS()))
}
+// The motion gate reads declarations as a browser does. A quoted brace
+// is text, so "{" in one reduce rule and "}" in a later rule do not
+// pair up and carry .missed into the reduce block; a quoted
+// "transition: none" is a string, not a disable; and TRANSITION is
+// transition. Each hole lets a transition run under reduced motion. The last stylesheet is the
+// control: its one transition is disabled, so the gate passes.
+func TestTheMotionGateHasNoKnownHoles(t *testing.T) {
+ for _, c := range []struct{ css, missed string }{
+ {`.ok { transition: opacity 1s; }
+@media (prefers-reduced-motion: reduce) {
+ .ok { transition: none; }
+ .marker { --rst-open: "{"; }
+}
+.missed { transition: opacity 1s; }
+.marker { --rst-close: "}"; }`, ".missed"},
+ {`.moving { transition: opacity 1s; }
+@media (prefers-reduced-motion: reduce) {
+ .moving { --rst-proof: "; transition: none;"; }
+}`, ".moving"},
+ // A property name is not case-sensitive to a browser.
+ {`.loud { TRANSITION: opacity 1s; }
+@media (prefers-reduced-motion: reduce) {
+ .other { transition: none; }
+}`, ".loud"},
+ } {
+ problems, _, err := motionProblems(c.css)
+ named := false
+ for _, p := range problems {
+ named = named || strings.Contains(p, `"`+c.missed+`"`)
+ }
+ if err == nil && !named {
+ t.Errorf("the motion gate passes %s, which still moves under reduced motion:\n%s", c.missed, c.css)
+ }
+ }
+ const control = `.ok { transition: opacity 1s; } @media (prefers-reduced-motion: reduce) { .ok { transition: none; } }`
+ if problems, tested, err := motionProblems(control); err != nil || len(problems) != 0 || tested != 1 {
+ t.Errorf("the motion gate on a disabled transition: %v, %d checked, err %v; want no problems and 1 checked", problems, tested, err)
+ }
+}
+
// braceMatchEnd reads a quoted brace as text, so the block it finds
// ends at the brace that closes it, not at one inside a string.
func TestBraceMatchEndReadsAStringAsText(t *testing.T) {
@@ -530,39 +498,57 @@ func TestBraceMatchEndReadsAStringAsText(t *testing.T) {
func assertReducedMotion(t *testing.T, name, css string) {
t.Helper()
- css = stripCSSComments(t, css)
- reduceBlocks, rest := reducedMotionBlocks(t, css)
- if len(reduceBlocks) == 0 {
- t.Fatal(name + " declares no @media (prefers-reduced-motion: reduce) block at all")
+ problems, tested, err := motionProblems(css)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
}
- idx := buildReduceIndex(t, reduceBlocks)
+ for _, p := range problems {
+ t.Error(p)
+ }
+ if tested == 0 {
+ t.Fatal("found no transition/animation declarations to check outside reduce blocks — the parser likely broke, not that " + name + " lost every transition")
+ }
+}
- tested := 0
- for _, rule := range leafRules(t, rest) {
- decls := motionDecls(rule.body)
- if len(decls) == 0 {
+// motionProblems is the motion gate over one stylesheet: a problem for
+// each selector's transition or animation that no reduce block
+// disables, and how many it checked. It reads the stylesheet with
+// declarationsOf, so a brace or a "transition: none" inside a string
+// is text, as it is to a browser, and a selector list is split where
+// a browser splits it.
+func motionProblems(css string) (problems []string, tested int, err error) {
+ decls, err := declarationsOf("", css)
+ if err != nil {
+ return nil, 0, err
+ }
+ const reduce = "@media (prefers-reduced-motion: reduce)"
+ inReduce := func(d declaration) bool {
+ return slices.Contains(strings.Split(d.Context, " > "), reduce)
+ }
+ motion := func(d declaration) bool { return d.Property == "transition" || d.Property == "animation" }
+ type disable struct{ selector, property string }
+ disabled := map[disable]bool{}
+ for _, d := range decls {
+ if inReduce(d) && motion(d) && d.Value == "none" {
+ disabled[disable{d.Selector, d.Property}] = true
+ }
+ }
+ if !slices.ContainsFunc(decls, inReduce) {
+ return nil, 0, fmt.Errorf("declares no %s block at all", reduce)
+ }
+ for _, d := range decls {
+ // none is already disabled unconditionally; nothing to gate.
+ if !motion(d) || inReduce(d) || d.Value == "none" {
continue
}
- for _, sel := range selectorList(rule.selector) {
- for _, d := range decls {
- if d.val == "none" {
- // Already disabled unconditionally; nothing to gate.
- continue
- }
- tested++
- if reducedMotionAllowlist[sel] {
- continue
- }
- if !idx[sel][d.prop] {
- t.Errorf("selector %q declares %s: %s with no exact-selector %q: none under prefers-reduced-motion: reduce (add one, or add %q to reducedMotionAllowlist with a reason)",
- sel, d.prop, d.val, d.prop+": none", sel)
- }
- }
+ tested++
+ if reducedMotionAllowlist[d.Selector] || disabled[disable{d.Selector, d.Property}] {
+ continue
}
+ problems = append(problems, fmt.Sprintf("selector %q declares %s: %s with no exact-selector %q: none under prefers-reduced-motion: reduce (add one, or add %q to reducedMotionAllowlist with a reason)",
+ d.Selector, d.Property, d.Value, d.Property+": none", d.Selector))
}
- if tested == 0 {
- t.Fatal("found no transition/animation declarations to check outside reduce blocks — the parser likely broke, not that " + name + " lost every transition")
- }
+ return problems, tested, nil
}
// The one non-partial asset this package ships gets the same bar as the