| 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 |