rastrillo / fiesta Public

Ask the browser whether anything moves under reduced motion

The reduced-motion gate reads the stylesheets and remembers whether
any reduce block disables a selector's transition or animation. It
never checks that the disable wins. A later transition for the same
selector, or an !important one, beats a disable that came first, and
the gate still passes: Astra's later [rst-snippet] transition and an
!important variant both give it 0 problems.

Ordering and weighing the cascade in Go was the rejected fix: it would
be one more CSS reader to get wrong, and the last rounds were spent
on readers in check that read a stylesheet differently from a
browser. The browser applies the cascade, so the new drive asks it. Under emulated reduced motion, with
a mouse that hovers, it loads every state of every example (168,
the layouts and the column shell among them) in day, plain and signal
at 1280 and 390. It puts every element through eight passes: at rest,
hovered, pressed, tapped, focused, targeted, opened, and opened and
hovered. Each element and its ::before, ::after, ::marker,
::placeholder and ::backdrop must compute no transition with a
duration and no animation, and document.getAnimations() must be empty,
which also catches an animation a script starts. Every transition
counts, fades included: DESIGN.md says nothing moves and nothing fades
under reduced motion, every shipped transition has a disable, and a
custom-property transition (the switch's knob, the press) moves things
without naming transform.

Shipped CSS passes in all six parts. Nothing in css/ changes.

Two controls keep the drive honest. Four losing disables appended to
tokens.css under the snippet's example must each be named: Astra's
later transition, the !important one, a hover-only transition and an
animation on ::before. Without reduced motion, the drive must see
what the stylesheets ship, each in the pass that reaches it, and not
in the passes where a shipped rule stops it. A pass that forced
nothing would otherwise leave the main drive reading every page at
rest and passing.

The static gate stays as the early warning, and its comment now says
the browser drive is the authority and why.

The drive adds about 125 weight-seconds to check's browser tests, and
the check slices were already the slowest roles on the fleet, so
check goes from 13 slices to 14 and 50-suite@48 becomes @49. Each
slice deals 78 to 79 weight-seconds, against 76 before and 85 at 13.
The weights are docs-t1 seconds scaled by 1.17 until a runner times
them.

Red: with Astra's CSS appended to tokens.css, and again with the
!important variant, TestReducedMotionDisablesEveryTransition passes
and TestNothingMovesUnderReducedMotion1of6 fails, naming
div[rst-snippet]. Mutations of the drive, each red and reverted: no
state forced (D1), no pseudo-elements read (D2), getAnimations not
read (D3), details not opened (D4), reduced motion not emulated (D5),
only the first transition property read (D6), the desktop width only
(D7), no hover (D8), the theme link not rewritten (D9).
Human

Prompt

Fix Astra's round-four finding 7 on Fiesta 1.1's branch. The reduced-motion gate (check/css_test.go:593) remembers whether any reduced-motion declaration says none for a selector. It never checks that the declaration wins. A later transition: transform 0.2s for the same selector, or an !important one, beats the disable, and the gate still passes. Don't model the cascade in Go. Ask the browser, which is the ground truth. Find out first whether shipped CSS has this problem today: load every gallery example frame and the column demo, in each theme, with prefers-reduced-motion: reduce emulated, and list every element that still moves. Add that check as a browser test that fails on any disallowed motion under reduced motion. Make it red first with Astra's example and with an !important variant. Keep the static gate too, as a fast early warning. In a comment, say the browser test is the authority and why. If shipped CSS leaks motion, stop and report before changing css/. Give the new test a role and a weight.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d023e7585bd824c35216367d17b84b408f263661 parent a92e384
5 files changed, +454 −5
  • .amadan/ci.d/{50-suite@48 => 50-suite@49} +0 −0
  • Makefile +1 −1
  • check/css_test.go +13 −3
  • check/reduced_motion_browser_test.go +429 −0
  • hack/test-weights.txt +11 −1

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.223s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 158347 bytes, 23104 bytes gzipped (22.6 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 158442 bytes, 23217 bytes gzipped (22.7 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 161261 bytes, 23823 bytes gzipped (23.3 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.025s
=== history ===
history: 53 commits; release tags: v1.0.0
=== suite ===
--- shard 0 of 49 ---
==> shard 0 of 49: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh root 'internal/gallery@3 cmd/fiesta@2 internal/www@2'
ok  	amadan.net/rastrillo/fiesta	4.141s
ok  	amadan.net/rastrillo/fiesta/check	8.688s
ok  	amadan.net/rastrillo/fiesta/example	0.190s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.153s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.233s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.007s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.016s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.310s
ok  	amadan.net/rastrillo/fiesta/internal/rowrule	0.017s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.157s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.033s
suite: root took 14s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.300000s 0m1.830000s
--- shard 1 of 49 ---
==> shard 1 of 49: make test.internal.gallery~1of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~1of3'
gotest: slice 1 of 3 of ./internal/gallery/ runs 36 of 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.569s
suite: test.internal.gallery~1of3 took 69s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m47.630000s 0m0.980000s
--- shard 2 of 49 ---
==> shard 2 of 49: make test.internal.gallery~2of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~2of3'
gotest: slice 2 of 3 of ./internal/gallery/ runs 36 of 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	69.529s
suite: test.internal.gallery~2of3 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.350000s 0m1.450000s
--- shard 3 of 49 ---
==> shard 3 of 49: make test.internal.gallery~3of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~3of3'
gotest: slice 3 of 3 of ./internal/gallery/ runs 36 of 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.008s
suite: test.internal.gallery~3of3 took 64s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.500000s 0m1.140000s
--- shard 4 of 49 ---
==> shard 4 of 49: make test.cmd.fiesta~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~1of2'
gotest: slice 1 of 2 of ./cmd/fiesta/ runs 7 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	50.618s
suite: test.cmd.fiesta~1of2 took 52s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m32.430000s 0m1.990000s
--- shard 5 of 49 ---
==> shard 5 of 49: make test.cmd.fiesta~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~2of2'
gotest: slice 2 of 2 of ./cmd/fiesta/ runs 5 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	53.850s
suite: test.cmd.fiesta~2of2 took 55s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m35.030000s 0m2.220000s
--- shard 6 of 49 ---
==> shard 6 of 49: make test.internal.www~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~1of2'
gotest: slice 1 of 2 of ./internal/www/ runs 11 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	64.334s
suite: test.internal.www~1of2 took 68s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.830000s 0m1.480000s
--- shard 7 of 49 ---
==> shard 7 of 49: make test.internal.www~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~2of2'
gotest: slice 2 of 2 of ./internal/www/ runs 14 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	58.397s
suite: test.internal.www~2of2 took 63s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.720000s 0m1.450000s
--- shard 8 of 49 ---
==> shard 8 of 49: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
suite: staticcheck took 10s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m9.140000s 0m0.690000s
--- shard 9 of 49 ---
==> shard 9 of 49: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
suite: govulncheck took 7s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m3.190000s 0m0.370000s
--- shard 10 of 49 ---
==> shard 10 of 49: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
1:50PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
1:50PM INF 53 commits scanned.
1:50PM INF scanned ~9411958 bytes (9.41 MB) in 5.23s
1:50PM INF no leaks found
suite: gitleaks took 7s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m8.230000s 0m0.290000s
--- shard 11 of 49 ---
==> shard 11 of 49: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: ./internal/harness/ runs its 10 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/harness	4.515s
suite: browser.internal.harness took 10s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m1.620000s 0m1.240000s
--- shard 12 of 49 ---
==> shard 12 of 49: make browser.check~1of14
./hack/gotest.sh browser 'check~1of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 1 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	95.521s
suite: browser.check~1of14 took 104s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.480000s 0m14.780000s
--- shard 13 of 49 ---
==> shard 13 of 49: make browser.check~2of14
./hack/gotest.sh browser 'check~2of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 2 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	100.812s
suite: browser.check~2of14 took 109s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.800000s 0m13.400000s
--- shard 14 of 49 ---
==> shard 14 of 49: make browser.check~3of14
./hack/gotest.sh browser 'check~3of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 3 of 14 of ./check/ runs 16 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	95.665s
suite: browser.check~3of14 took 104s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.510000s 0m15.100000s
--- shard 15 of 49 ---
==> shard 15 of 49: make browser.check~4of14
./hack/gotest.sh browser 'check~4of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 4 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	104.004s
suite: browser.check~4of14 took 112s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m57.960000s 0m16.920000s
--- shard 16 of 49 ---
==> shard 16 of 49: make browser.check~5of14
./hack/gotest.sh browser 'check~5of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 5 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	97.141s
suite: browser.check~5of14 took 105s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m55.460000s 0m24.180000s
--- shard 17 of 49 ---
==> shard 17 of 49: make browser.check~6of14
./hack/gotest.sh browser 'check~6of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 6 of 14 of ./check/ runs 18 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	85.424s
suite: browser.check~6of14 took 93s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m39.860000s 0m13.680000s
--- shard 18 of 49 ---
==> shard 18 of 49: make browser.check~7of14
./hack/gotest.sh browser 'check~7of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 7 of 14 of ./check/ runs 18 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	85.515s
suite: browser.check~7of14 took 94s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.860000s 0m14.100000s
--- shard 19 of 49 ---
==> shard 19 of 49: make browser.check~8of14
./hack/gotest.sh browser 'check~8of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 8 of 14 of ./check/ runs 16 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	95.310s
suite: browser.check~8of14 took 104s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.210000s 0m17.850000s
--- shard 20 of 49 ---
==> shard 20 of 49: make browser.check~9of14
./hack/gotest.sh browser 'check~9of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 9 of 14 of ./check/ runs 16 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	99.418s
suite: browser.check~9of14 took 108s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.620000s 0m18.180000s
--- shard 21 of 49 ---
==> shard 21 of 49: make browser.check~10of14
./hack/gotest.sh browser 'check~10of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 10 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	85.708s
suite: browser.check~10of14 took 94s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.000000s 0m10.970000s
--- shard 22 of 49 ---
==> shard 22 of 49: make browser.check~11of14
./hack/gotest.sh browser 'check~11of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 11 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	89.419s
suite: browser.check~11of14 took 98s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.620000s 0m14.490000s
--- shard 23 of 49 ---
==> shard 23 of 49: make browser.check~12of14
./hack/gotest.sh browser 'check~12of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 12 of 14 of ./check/ runs 16 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	91.365s
suite: browser.check~12of14 took 100s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m49.220000s 0m14.780000s
--- shard 24 of 49 ---
==> shard 24 of 49: make browser.check~13of14
./hack/gotest.sh browser 'check~13of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 13 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	98.545s
suite: browser.check~13of14 took 107s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.800000s 0m17.870000s
--- shard 25 of 49 ---
==> shard 25 of 49: make browser.check~14of14
./hack/gotest.sh browser 'check~14of14'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 14 of 14 of ./check/ runs 17 of 236 browser tests
ok  	amadan.net/rastrillo/fiesta/check	86.814s
suite: browser.check~14of14 took 95s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.370000s 0m14.290000s
--- shard 26 of 49 ---
==> shard 26 of 49: make browser.internal.gallery~1of18
./hack/gotest.sh browser 'internal.gallery~1of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	39.776s
suite: browser.internal.gallery~1of18 took 46s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.380000s 0m3.390000s
--- shard 27 of 49 ---
==> shard 27 of 49: make browser.internal.gallery~2of18
./hack/gotest.sh browser 'internal.gallery~2of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	51.169s
suite: browser.internal.gallery~2of18 took 58s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.700000s 0m2.790000s
--- shard 28 of 49 ---
==> shard 28 of 49: make browser.internal.gallery~3of18
./hack/gotest.sh browser 'internal.gallery~3of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	58.431s
suite: browser.internal.gallery~3of18 took 65s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.610000s 0m3.550000s
--- shard 29 of 49 ---
==> shard 29 of 49: make browser.internal.gallery~4of18
./hack/gotest.sh browser 'internal.gallery~4of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	50.094s
suite: browser.internal.gallery~4of18 took 56s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.440000s 0m2.740000s
--- shard 30 of 49 ---
==> shard 30 of 49: make browser.internal.gallery~5of18
./hack/gotest.sh browser 'internal.gallery~5of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 5 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.922s
suite: browser.internal.gallery~5of18 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.030000s 0m5.180000s
--- shard 31 of 49 ---
==> shard 31 of 49: make browser.internal.gallery~6of18
./hack/gotest.sh browser 'internal.gallery~6of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 6 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	56.372s
suite: browser.internal.gallery~6of18 took 62s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.020000s 0m3.710000s
--- shard 32 of 49 ---
==> shard 32 of 49: make browser.internal.gallery~7of18
./hack/gotest.sh browser 'internal.gallery~7of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 7 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	57.329s
suite: browser.internal.gallery~7of18 took 64s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.060000s 0m2.480000s
--- shard 33 of 49 ---
==> shard 33 of 49: make browser.internal.gallery~8of18
./hack/gotest.sh browser 'internal.gallery~8of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 8 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	60.175s
suite: browser.internal.gallery~8of18 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.010000s 0m4.470000s
--- shard 34 of 49 ---
==> shard 34 of 49: make browser.internal.gallery~9of18
./hack/gotest.sh browser 'internal.gallery~9of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 9 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	68.909s
suite: browser.internal.gallery~9of18 took 75s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.240000s 0m7.280000s
--- shard 35 of 49 ---
==> shard 35 of 49: make browser.internal.gallery~10of18
./hack/gotest.sh browser 'internal.gallery~10of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 10 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.375s
suite: browser.internal.gallery~10of18 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.310000s 0m2.490000s
--- shard 36 of 49 ---
==> shard 36 of 49: make browser.internal.gallery~11of18
./hack/gotest.sh browser 'internal.gallery~11of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 11 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	65.216s
suite: browser.internal.gallery~11of18 took 72s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.620000s 0m2.680000s
--- shard 37 of 49 ---
==> shard 37 of 49: make browser.internal.gallery~12of18
./hack/gotest.sh browser 'internal.gallery~12of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 12 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.934s
suite: browser.internal.gallery~12of18 took 74s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.210000s 0m4.700000s
--- shard 38 of 49 ---
==> shard 38 of 49: make browser.internal.gallery~13of18
./hack/gotest.sh browser 'internal.gallery~13of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 13 of 18 of ./internal/gallery/ runs 4 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	66.782s
suite: browser.internal.gallery~13of18 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.230000s 0m4.980000s
--- shard 39 of 49 ---
==> shard 39 of 49: make browser.internal.gallery~14of18
./hack/gotest.sh browser 'internal.gallery~14of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 14 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	68.247s
suite: browser.internal.gallery~14of18 took 75s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.300000s 0m4.880000s
--- shard 40 of 49 ---
==> shard 40 of 49: make browser.internal.gallery~15of18
./hack/gotest.sh browser 'internal.gallery~15of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 15 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	69.592s
suite: browser.internal.gallery~15of18 took 76s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.760000s 0m5.370000s
--- shard 41 of 49 ---
==> shard 41 of 49: make browser.internal.gallery~16of18
./hack/gotest.sh browser 'internal.gallery~16of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 16 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	66.177s
suite: browser.internal.gallery~16of18 took 72s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.960000s 0m4.100000s
--- shard 42 of 49 ---
==> shard 42 of 49: make browser.internal.gallery~17of18
./hack/gotest.sh browser 'internal.gallery~17of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 17 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	69.509s
suite: browser.internal.gallery~17of18 took 76s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.220000s 0m5.470000s
--- shard 43 of 49 ---
==> shard 43 of 49: make browser.internal.gallery~18of18
./hack/gotest.sh browser 'internal.gallery~18of18'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 18 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	70.909s
suite: browser.internal.gallery~18of18 took 77s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.840000s 0m6.260000s
--- shard 44 of 49 ---
==> shard 44 of 49: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on ip-172-31-27-215, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: ./internal/gallery/history/ runs its 3 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery/history	34.272s
suite: browser.internal.gallery.history took 40s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.850000s 0m1.990000s
--- shard 45 of 49 ---
==> shard 45 of 49: make browser.internal.www~1of4
./hack/gotest.sh browser 'internal.www~1of4'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 5 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	81.144s
suite: browser.internal.www~1of4 took 87s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.170000s 0m2.130000s
--- shard 46 of 49 ---
==> shard 46 of 49: make browser.internal.www~2of4
./hack/gotest.sh browser 'internal.www~2of4'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 7 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	80.185s
suite: browser.internal.www~2of4 took 87s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.190000s 0m3.100000s
--- shard 47 of 49 ---
==> shard 47 of 49: make browser.internal.www~3of4
./hack/gotest.sh browser 'internal.www~3of4'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 6 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	82.190s
suite: browser.internal.www~3of4 took 89s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.570000s 0m2.860000s
--- shard 48 of 49 ---
==> shard 48 of 49: make browser.internal.www~4of4
./hack/gotest.sh browser 'internal.www~4of4'
gotest: on ip-172-31-27-215, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 6 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	79.859s
suite: browser.internal.www~4of4 took 86s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.170000s 0m2.860000s
diff --git a/.amadan/ci.d/50-suite@48 b/.amadan/ci.d/50-suite@49
similarity index 100%
rename from .amadan/ci.d/50-suite@48
rename to .amadan/ci.d/50-suite@49
diff --git a/Makefile b/Makefile
index 4f40e38..2c0e41a 100644
--- a/Makefile
+++ b/Makefile
@@ -64,7 +64,7 @@ ci: gofmt no-rastrillo size history suite
# the two axe sweeps, the HTML tab's renders, the floors drive). The
# tasks that add a browser package append here and rename 50-suite@N
# to match.
-BROWSER_PKGS := internal/harness check@13 internal/gallery@18 internal/gallery/history internal/www@4
+BROWSER_PKGS := internal/harness check@14 internal/gallery@18 internal/gallery/history internal/www@4
ROOT_SPLIT := internal/gallery@3 cmd/fiesta@2 internal/www@2
go-roles = $(foreach p,$(2),$(if $(findstring @,$(p)),$(call go-slices,$(1),$(subst /,.,$(word 1,$(subst @, ,$(p)))),$(word 2,$(subst @, ,$(p)))),$(1).$(subst /,.,$(p))))
go-slices = $(foreach k,$(shell seq 1 $(3)),$(1).$(2)~$(k)of$(3))
diff --git a/check/css_test.go b/check/css_test.go
index 5d6765d..5f811dc 100644
--- a/check/css_test.go
+++ b/check/css_test.go
@@ -485,9 +485,19 @@ func TestLeafRulesReadABraceInAStringAsText(t *testing.T) {
// declaring a *different* motion property on an already-listed selector
// (selector reappearing is not enough — the property must actually be
// neutralized), and a new selector merely overlapping textually with one
-// already covered ("[rst-tip]" vs "[rst-tip]::after") — hence the exact,
-// comma-split selector match in selectorList/buildReduceIndex rather than
-// a substring check.
+// already covered ("[rst-tip]" vs "[rst-tip]::after") — hence the exact
+// selector match in motionProblems rather than a substring check.
+//
+// This gate is the early warning, not the authority. It finds a
+// disable but does not check that the disable wins: a later rule for
+// the same selector, or an !important one, beats a disable that came
+// first, and this gate still passes. Telling which declaration wins
+// means ordering and weighing the whole cascade, and a copy of that in
+// Go would be one more reader to get wrong. The authority is the six
+// parts of TestNothingMovesUnderReducedMotion
+// (reduced_motion_browser_test.go), which ask the browser what every
+// element on every example page computes under reduced motion. This
+// one runs in a second with no browser, and names the selector to fix.
func TestReducedMotionDisablesEveryTransition(t *testing.T) {
assertReducedMotion(t, "tokens.css", string(fiesta.TokensCSS()))
}
diff --git a/check/reduced_motion_browser_test.go b/check/reduced_motion_browser_test.go
new file mode 100644
index 0000000..a7a626a
--- /dev/null
+++ b/check/reduced_motion_browser_test.go
@@ -0,0 +1,429 @@
+//go:build browser
+
+// Under reduced motion nothing on a Fiesta page moves and nothing
+// fades: every transition and animation the stylesheets declare has a
+// disable under @media (prefers-reduced-motion: reduce). This drive is
+// the authority on that promise. It asks the browser, which applies
+// the cascade, rather than reading the stylesheets: a disable that
+// loses, to a later rule or to an !important one, reads as a disable
+// to any reader that does not order and weigh every declaration, and
+// the browser is the only reader that cannot get that wrong. The
+// static gate in css_test.go stays as a fast early warning.
+package check
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/cdp"
+ "github.com/chromedp/cdproto/css"
+ "github.com/chromedp/cdproto/dom"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/example"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// motionPass is one state the drive puts every element of a page in
+// before it reads what moves. The states are forced on every element
+// at once, through the inspector, since a pointer over one element
+// hovers each of its ancestors too and a row's hover rule names the
+// row, not the link under the pointer. Every <details> is opened in
+// the last two, since an open menu is where the drop-down animation
+// and the rules under an open menu live.
+type motionPass struct {
+ name string
+ states []string
+ open bool
+}
+
+var motionPasses = []motionPass{
+ {name: "at rest"},
+ {name: "hovered", states: []string{"hover"}},
+ {name: "pressed", states: []string{"hover", "active"}},
+ {name: "tapped", states: []string{"active"}},
+ {name: "focused", states: []string{"focus", "focus-visible", "focus-within"}},
+ {name: "targeted", states: []string{"target"}},
+ {name: "opened", open: true},
+ {name: "opened and hovered", states: []string{"hover"}, open: true},
+}
+
+// motionWidths are the two widths the stylesheets switch at: the
+// shell's phone rules (the menu that drops in, the index rows) apply
+// only under 800px.
+var motionWidths = []int64{1280, 390}
+
+// motionDoc is one example state as the drive serves it.
+type motionDoc struct{ name, path string }
+
+// motionSite serves every state of every example, layouts included, or
+// of the one example called only if it is not "", at the depth the
+// tree keeps it, examples/<name>/<state>.html, so its
+// relative links resolve. The theme each document links is rewritten
+// to theme, and /css/tokens.css answers with tokens, so a control can
+// serve a stylesheet with a planted fault. Everything else under css/,
+// js/ and examples/assets/ is the tree's.
+func motionSite(t *testing.T, theme string, tokens []byte, only string) ([]motionDoc, func(string) http.Handler) {
+ t.Helper()
+ m, err := example.Load(fiesta.FS())
+ if err != nil {
+ t.Fatal(err)
+ }
+ const day = "../../css/themes/day.css"
+ var docs []motionDoc
+ pages := map[string]string{}
+ for _, e := range m.Examples {
+ if only != "" && only != e.Name {
+ continue
+ }
+ for _, s := range e.States {
+ doc, err := example.Document(fiesta.FS(), e.Name, s.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(doc, day) != 1 {
+ t.Fatalf("%s/%s links %s %d times, want once: the drive cannot put the theme under test in its place", e.Name, s.ID, day, strings.Count(doc, day))
+ }
+ d := motionDoc{name: e.Name + "/" + s.ID, path: "/examples/" + e.Name + "/" + s.ID + ".html"}
+ pages[d.path] = strings.Replace(doc, day, "../../css/themes/"+theme+".css", 1)
+ docs = append(docs, d)
+ }
+ }
+ return docs, func(string) http.Handler {
+ mux := http.NewServeMux()
+ tree := http.FileServerFS(fiesta.FS())
+ for _, prefix := range []string{"/css/", "/js/", "/examples/assets/"} {
+ mux.Handle("GET "+prefix, tree)
+ }
+ mux.HandleFunc("GET /css/tokens.css", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/css")
+ w.Write(tokens)
+ })
+ for path, doc := range pages {
+ doc := doc
+ mux.HandleFunc("GET "+path, func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, doc)
+ })
+ }
+ return mux
+ }
+}
+
+// motionReading is what one page in one pass says about itself.
+type motionReading struct {
+ Reduce, Hover bool
+ Theme int
+ Elements int
+ Moving []string
+}
+
+// readMotion lists what still moves on the page, in words: each
+// element, or its ::before, ::after, ::marker, ::placeholder or
+// ::backdrop, whose computed transition has a duration on any property
+// or whose computed animation names one, and each animation the
+// document is running, which also catches one a script started. Its
+// own reading says whether reduced motion and hover are on, how many
+// elements it read and how many rules the theme under test holds, so
+// a page that was not what the drive thinks it is cannot pass.
+const readMotion = `(theme => {
+ const secs = v => v.split(",").map(s => { s = s.trim(); return s.endsWith("ms") ? parseFloat(s) / 1000 : parseFloat(s); });
+ const label = el => { let s = el.localName; if (el.id) s += "#" + el.id;
+ for (const a of el.attributes) if (a.name.startsWith("rst-")) s += "[" + a.name + (a.value ? '="' + a.value + '"' : "") + "]";
+ for (const c of el.classList) if (c.startsWith("rst-")) s += "." + c; return s; };
+ const name = el => { const parts = []; for (let e = el, i = 0; e && i < 3; e = e.parentElement, i++) parts.unshift(label(e)); return parts.join(" > "); };
+ const moving = [];
+ const read = (el, pseudo) => {
+ const cs = getComputedStyle(el, pseudo || null);
+ if ((pseudo === "::before" || pseudo === "::after") && (cs.content === "none" || cs.content === "normal")) return;
+ const props = cs.transitionProperty.split(",").map(s => s.trim()), durs = secs(cs.transitionDuration);
+ props.forEach((p, i) => { const d = durs[i % durs.length]; if (p !== "none" && d > 0) moving.push(name(el) + pseudo + " transitions " + p + " over " + d + "s"); });
+ for (const n of cs.animationName.split(",").map(s => s.trim())) if (n !== "none") moving.push(name(el) + pseudo + " runs the animation " + n);
+ };
+ const all = document.querySelectorAll("*");
+ for (const el of all) for (const p of ["", "::before", "::after", "::marker", "::placeholder", "::backdrop"]) read(el, p);
+ for (const a of document.getAnimations()) {
+ const e = a.effect;
+ moving.push((e && e.target ? name(e.target) + (e.pseudoElement || "") : "the document") + " is running " + a.constructor.name + " " + (a.animationName || a.transitionProperty || a.id || ""));
+ }
+ const sheet = [...document.styleSheets].find(s => s.href && s.href.endsWith("/css/themes/" + theme + ".css"));
+ return JSON.stringify({Reduce: matchMedia("(prefers-reduced-motion: reduce)").matches, Hover: matchMedia("(hover: hover)").matches,
+ Theme: sheet ? sheet.cssRules.length : 0, Elements: all.length, Moving: moving});
+})`
+
+// forceEveryElement pins states on every element of the page, which
+// replaces whatever was pinned before: an empty list releases them.
+// The calls go out together, not one round trip at a time, since a
+// layout has hundreds of elements and each pass pins all of them.
+func forceEveryElement(states []string) chromedp.ActionFunc {
+ return func(c context.Context) error {
+ root, err := dom.GetDocument().Do(c)
+ if err != nil {
+ return err
+ }
+ ids, err := dom.QuerySelectorAll(root.NodeID, "*").Do(c)
+ if err != nil {
+ return err
+ }
+ if states == nil {
+ states = []string{}
+ }
+ var wg sync.WaitGroup
+ errs := make(chan error, len(ids))
+ gate := make(chan struct{}, 32)
+ for _, id := range ids {
+ wg.Add(1)
+ gate <- struct{}{}
+ go func(id cdp.NodeID) {
+ defer wg.Done()
+ defer func() { <-gate }()
+ if err := css.ForcePseudoState(id, states).Do(c); err != nil {
+ errs <- err
+ }
+ }(id)
+ }
+ wg.Wait()
+ close(errs)
+ return <-errs
+ }
+}
+
+// motionFinding is one thing that moves: where the drive first saw
+// it, how many times it saw it in all, and the passes it saw it in,
+// each as "<pass> at <width>".
+type motionFinding struct {
+ where string
+ times int
+ in map[string]bool
+}
+
+// sweepMotion loads each of docs at each width in one browser, with
+// reduced motion emulated or not and a mouse that hovers, puts every
+// page through every pass, and returns what moved, keyed by what the
+// page said. It fails the test if a page's reduced motion is not what
+// the caller asked for, has no hover, did not load the theme under
+// test or has no elements.
+func sweepMotion(t *testing.T, theme string, docs []motionDoc, site func(string) http.Handler, widths []int64, reduce bool) map[string]*motionFinding {
+ t.Helper()
+ rig := harness.New(t, site, harness.WithHover())
+ ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second)
+ defer cancel()
+ preference := "no-preference"
+ if reduce {
+ preference = "reduce"
+ }
+ mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: preference}}))
+ found := map[string]*motionFinding{}
+ for _, width := range widths {
+ for _, d := range docs {
+ mustRun(t, ctx, chromedp.EmulateViewport(width, 900), chromedp.Navigate(rig.Origin+d.path), chromedp.WaitReady("body", chromedp.ByQuery),
+ chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, awaitPromise),
+ chromedp.ActionFunc(func(c context.Context) error {
+ if err := dom.Enable().Do(c); err != nil {
+ return err
+ }
+ return css.Enable().Do(c)
+ }))
+ for _, pass := range motionPasses {
+ where := fmt.Sprintf("%s at %d, %s, in %s", d.name, width, pass.name, theme)
+ if pass.open {
+ mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll("details").forEach(d => d.open = true), true`, nil))
+ }
+ mustRun(t, ctx, forceEveryElement(pass.states))
+ var r motionReading
+ at(t, ctx, readMotion+`(`+jsStr(theme)+`)`, &r)
+ if r.Reduce != reduce || !r.Hover {
+ t.Fatalf("%s: reduced motion %v, hover %v; the drive wants reduced motion %v and hover", where, r.Reduce, r.Hover, reduce)
+ }
+ if r.Theme == 0 {
+ t.Fatalf("%s: the page has not loaded themes/%s.css, so it is not the theme under test", where, theme)
+ }
+ if r.Elements == 0 {
+ t.Fatalf("%s: the page has no elements to read", where)
+ }
+ for _, m := range r.Moving {
+ f, ok := found[m]
+ if !ok {
+ f = &motionFinding{where: where, in: map[string]bool{}}
+ found[m] = f
+ }
+ f.times++
+ f.in[fmt.Sprintf("%s at %d", pass.name, width)] = true
+ }
+ }
+ }
+ }
+ return found
+}
+
+// reportMotion fails the test once for each thing that moved.
+func reportMotion(t *testing.T, found map[string]*motionFinding) {
+ t.Helper()
+ keys := make([]string, 0, len(found))
+ for k := range found {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ t.Errorf("under reduced motion, %s (first in %s; %d readings in all)", k, found[k].where, found[k].times)
+ }
+}
+
+// nothingMoves is the k-th of the six parts of
+// TestNothingMovesUnderReducedMotion: every state of every example,
+// layouts and the column shell among them, in one theme at one width.
+func nothingMoves(t *testing.T, k int) {
+ themes := fiesta.ThemeNames()
+ if len(themes)*len(motionWidths) != 6 {
+ t.Fatalf("fiesta ships %d themes and the drive reads %d widths; it is dealt into six parts, one a theme and width", len(themes), len(motionWidths))
+ }
+ theme, width := themes[k/2], motionWidths[k%2]
+ docs, site := motionSite(t, theme, fiesta.TokensCSS(), "")
+ if len(docs) < 150 {
+ t.Fatalf("the manifest lists %d states, want at least 150; a set this small is a broken manifest", len(docs))
+ }
+ reportMotion(t, sweepMotion(t, theme, docs, site, []int64{width}, true))
+}
+
+func TestNothingMovesUnderReducedMotion1of6(t *testing.T) { nothingMoves(t, 0) }
+func TestNothingMovesUnderReducedMotion2of6(t *testing.T) { nothingMoves(t, 1) }
+func TestNothingMovesUnderReducedMotion3of6(t *testing.T) { nothingMoves(t, 2) }
+func TestNothingMovesUnderReducedMotion4of6(t *testing.T) { nothingMoves(t, 3) }
+func TestNothingMovesUnderReducedMotion5of6(t *testing.T) { nothingMoves(t, 4) }
+func TestNothingMovesUnderReducedMotion6of6(t *testing.T) { nothingMoves(t, 5) }
+
+// The drive's own controls: a copy of tokens.css with a disable that
+// loses, served under the snippet's example, must be named as moving.
+// The first two pass the static gate, which would have to order and
+// weigh the cascade to see them. The last two it does see; here they
+// show the drive reaches a hovered element and a pseudo-element.
+var losingDisables = []struct{ name, css, moving string }{
+ // A later rule for the same selector wins over the disable, which
+ // came first (Astra's example).
+ {"a later transition", `
+@media (prefers-reduced-motion: reduce) {
+ .rst-snippet, [rst-snippet] { transition: none; }
+}
+.rst-snippet, [rst-snippet] { transition: transform 0.2s; }
+.rst-snippet:hover, [rst-snippet]:hover { transform: translateY(-2px); }
+`, "[rst-snippet] transitions transform over 0.2s"},
+ // !important wins over a disable that is not, wherever it stands.
+ {"an !important transition", `
+.rst-snippet, [rst-snippet] { transition: transform 0.2s !important; }
+@media (prefers-reduced-motion: reduce) {
+ .rst-snippet, [rst-snippet] { transition: none; }
+}
+`, "[rst-snippet] transitions transform over 0.2s"},
+ // Only a hovered snippet moves, so only the hovered pass sees it.
+ {"a transition on hover only", `
+@media (prefers-reduced-motion: reduce) {
+ .rst-snippet, [rst-snippet] { transition: none; }
+}
+.rst-snippet:hover, [rst-snippet]:hover { transition: translate 0.2s; }
+`, "[rst-snippet] transitions translate over 0.2s"},
+ // A pseudo-element is styled apart from its element.
+ {"an animation on ::before", `
+.rst-snippet::before, [rst-snippet]::before { animation: rst-spin 1s infinite; content: ""; }
+@media (prefers-reduced-motion: reduce) {
+ .rst-snippet, [rst-snippet] { animation: none; }
+}
+`, "[rst-snippet]::before runs the animation rst-spin"},
+}
+
+// TestTheMotionDriveSeesADisableThatLoses runs the drive on the
+// snippet's example, in day at 1280, once for each losing disable
+// appended to tokens.css, and wants it to name the snippet as moving.
+// The shipped tokens.css is the control: on it the same page moves
+// nowhere.
+func TestTheMotionDriveSeesADisableThatLoses(t *testing.T) {
+ const theme = "day"
+ shipped := fiesta.TokensCSS()
+ docs, site := motionSite(t, theme, shipped, "snippet")
+ if len(docs) == 0 {
+ t.Fatal("the manifest has no snippet example to plant a fault under")
+ }
+ if found := sweepMotion(t, theme, docs, site, []int64{1280}, true); len(found) != 0 {
+ reportMotion(t, found)
+ t.Fatal("the snippet moves under the shipped tokens.css; the controls below would prove nothing")
+ }
+ for _, c := range losingDisables {
+ t.Run(c.name, func(t *testing.T) {
+ tokens := append(append([]byte(nil), shipped...), c.css...)
+ if problems, _, err := motionProblems(string(tokens)); err == nil {
+ t.Logf("the static gate on this tokens.css: %d problems %v", len(problems), problems)
+ }
+ docs, site := motionSite(t, theme, tokens, "snippet")
+ found := sweepMotion(t, theme, docs, site, []int64{1280}, true)
+ named := false
+ for what := range found {
+ named = named || strings.Contains(what, c.moving)
+ }
+ if !named {
+ t.Errorf("the drive did not see %q; it saw %d other things move:", c.moving, len(found))
+ reportMotion(t, found)
+ }
+ })
+ }
+}
+
+// shippedMotions are motions the stylesheets ship, each with a pass
+// that must see it without reduced motion, or must not. Together they
+// show that each way the drive reaches a state takes: the hover, the
+// press, the target and the open menu, the pseudo-element, the phone
+// width and the animation a document runs. A pass that silently
+// forced nothing would leave the drive under reduced motion reading
+// every page at rest and passing.
+var shippedMotions = []struct {
+ what, in string
+ seen bool
+}{
+ {"[rst-btn] transitions translate over 0.3s", "at rest at 1280", true},
+ {"[rst-caret] transitions transform over 0.15s", "at rest at 1280", true},
+ {"[rst-tip]::after transitions opacity over 0.12s", "at rest at 1280", true},
+ {"[rst-switch-track] transitions --rst-switch-p over 0.24s", "at rest at 1280", true},
+ {"[rst-spin] runs the animation rst-spin", "at rest at 1280", true},
+ {"[rst-spin] is running CSSAnimation rst-spin", "at rest at 1280", true},
+ // A row fades in only once it is hovered, and lands at once when
+ // its link is pressed.
+ {"[rst-row] transitions --rst-hover over 0.25s", "hovered at 1280", true},
+ {"[rst-row] transitions --rst-hover over 0.25s", "at rest at 1280", false},
+ {"[rst-row] transitions --rst-hover over 0.25s", "pressed at 1280", false},
+ {"[rst-row] is running CSSTransition --rst-hover", "hovered at 1280", true},
+ // A pressed button lands at once. Not every button: a disabled or
+ // busy one is never pressed, so it keeps its transition.
+ {"[rst-snippet-copy] transitions translate over 0.3s", "at rest at 1280", true},
+ {"[rst-snippet-copy] transitions translate over 0.3s", "pressed at 1280", false},
+ {"[rst-snippet-copy] transitions translate over 0.3s", "tapped at 1280", false},
+ // The shell's menu drops in only when it is open, at a phone's
+ // width; the index row flashes only when it is the target.
+ {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 390", true},
+ {"[rst-shell-tail] runs the animation rst-shell-drop", "at rest at 390", false},
+ {"[rst-shell-tail] runs the animation rst-shell-drop", "opened at 1280", false},
+ {"runs the animation rst-shell-flash", "targeted at 390", true},
+ {"runs the animation rst-shell-flash", "at rest at 390", false},
+}
+
+// TestTheMotionDriveSeesWhatTheStylesheetsShip runs the drive over
+// every state in day at both widths without reduced motion, and holds
+// it to shippedMotions.
+func TestTheMotionDriveSeesWhatTheStylesheetsShip(t *testing.T) {
+ docs, site := motionSite(t, "day", fiesta.TokensCSS(), "")
+ found := sweepMotion(t, "day", docs, site, motionWidths, false)
+ for _, want := range shippedMotions {
+ seen := false
+ for what, f := range found {
+ seen = seen || strings.Contains(what, want.what) && f.in[want.in]
+ }
+ if seen != want.seen {
+ t.Errorf("without reduced motion, %q seen %s: %v, want %v", want.what, want.in, seen, want.seen)
+ }
+ }
+}
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index 0570ce8..940171a 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -23,11 +23,14 @@
# too, timed on 2026-10-30 by one -v run of just those tests on a
# runner, since the gate's log has no per-test times and the docs-t1
# times they were first listed at were off by up to a second either
-# way. A new test under 1.5s there is left out, so it weighs 1.
+# way. A new test under 1.5s there is left out, so it weighs 1. The
+# reduced-motion drives are docs-t1 seconds scaled by 1.17, until a
+# runner times them.
browser check TestEveryThemeAndShellRendersAsTheBaseline 45
browser check TestEveryThemeRendersAsItDidExceptDay 44
browser check TestEveryExampleStateMeetsTheFloorsWithAMouse 40
browser check TestTheReleaseRendersAsTheSource 38
+browser check TestTheMotionDriveSeesWhatTheStylesheetsShip 33
browser check TestOneOhLayoutsRenderAsTheyDid1of6 28
browser check TestEveryOneOhExampleRendersAsItDid2of8 26
browser check TestEveryOneOhExampleRendersAsItDid6of8 24
@@ -47,6 +50,12 @@ browser check TestOneOhLayoutsRenderAsTheyDid3of6 18
browser check TestOneOhLayoutsRenderAsTheyDid4of6 18
browser check TestTheLightStaysWithinItsFrameBudget 17
browser check TestTheProfileMenuIsADropUpAtTheRailsFoot 16
+browser check TestNothingMovesUnderReducedMotion1of6 15
+browser check TestNothingMovesUnderReducedMotion2of6 15
+browser check TestNothingMovesUnderReducedMotion3of6 15
+browser check TestNothingMovesUnderReducedMotion4of6 15
+browser check TestNothingMovesUnderReducedMotion5of6 15
+browser check TestNothingMovesUnderReducedMotion6of6 15
browser check TestFlatThemesFollowAnAppsOverrides 13
browser check TestTheReleaseScriptsDriveTheJourneys 10
browser check TestASwitchSlidesAndSnaps 8
@@ -89,6 +98,7 @@ browser check TestTheConsoleBarIsTheSameCard 3
browser check TestTheConsoleFoldsItsBarAndIndexesItsRail 3
browser check TestTheFootersMenusOpenOnScreen 3
browser check TestTheLightFollowsThePointerAndSettles 3
+browser check TestTheMotionDriveSeesADisableThatLoses 3
browser check TestTheTopbarMenuIsACardOverThePage 3
browser check TestTheWideLayoutIsUnchanged 3
browser check TestTomorrowParsesWhateverTheScriptsTiming 3