rastrillo / fiesta Public

The landing bunting rides the air the page scrolls through, and flicks when the page is shaken

Scrolling used to add a direction-blind gust that had faded, or found the loop asleep, by the time the row came back on screen, so scrolling back up barely moved the cloth. Now the page is the frame the bunting hangs in: its scroll velocity is a current of air against the page's motion that keeps decaying while the row is off screen and acts the moment it returns, and its acceleration pushes every free point the other way, so a quick up-and-down shake makes the pennants buckle and flick before they settle. Rising air lifts more than falling air and never beats gravity; small jolts are ignored; reduced motion still shows the still picture.

Paul tried it and approved the feel. Astra's review found a scroll after a long idle could fail to wake the cloth, a shake could leak into the calm rest pose, a long-hidden tab could resume a stale shake, and the shake test depended on frame rate; each is fixed with a drive that failed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from bunting-scroll into main.
claude agent, operated by paul@keymail.dev 162deafa58721bad4c58cd67dc7cf01277886fc8 parent 04d01e1
3 files changed, +458 −25
  • hack/test-weights.txt +6 −0
  • internal/www/landing_browser_test.go +309 −0
  • site/static/bunting.js +143 −25

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.146s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.025s
=== history ===
history: 6 commits; release tags: v1.0.0
=== suite ===
--- shard 0 of 48 ---
==> shard 0 of 48: 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	2.097s
ok  	amadan.net/rastrillo/fiesta/check	5.341s
ok  	amadan.net/rastrillo/fiesta/example	0.130s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.068s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.015s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.011s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.006s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.267s
ok  	amadan.net/rastrillo/fiesta/internal/site	2.814s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.012s
suite: root took 8s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m7.470000s 0m1.400000s
--- shard 1 of 48 ---
==> shard 1 of 48: 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 35 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	59.399s
suite: test.internal.gallery~1of3 took 61s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.760000s 0m1.090000s
--- shard 2 of 48 ---
==> shard 2 of 48: 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 34 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	58.294s
suite: test.internal.gallery~2of3 took 60s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.950000s 0m1.070000s
--- shard 3 of 48 ---
==> shard 3 of 48: 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 35 of 104 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	55.421s
suite: test.internal.gallery~3of3 took 57s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m40.630000s 0m1.110000s
--- shard 4 of 48 ---
==> shard 4 of 48: 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	46.335s
suite: test.cmd.fiesta~1of2 took 48s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.450000s 0m1.990000s
--- shard 5 of 48 ---
==> shard 5 of 48: 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	49.462s
suite: test.cmd.fiesta~2of2 took 51s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.960000s 0m2.200000s
--- shard 6 of 48 ---
==> shard 6 of 48: 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	59.130s
suite: test.internal.www~1of2 took 61s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.070000s 0m1.350000s
--- shard 7 of 48 ---
==> shard 7 of 48: 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	56.220s
suite: test.internal.www~2of2 took 58s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.870000s 0m1.460000s
--- shard 8 of 48 ---
==> shard 8 of 48: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
suite: staticcheck took 2s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m0.910000s 0m0.200000s
--- shard 9 of 48 ---
==> shard 9 of 48: 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 4s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m3.160000s 0m0.390000s
--- shard 10 of 48 ---
==> shard 10 of 48: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
11:35PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
11:35PM INF 6 commits scanned.
11:35PM INF scanned ~7819125 bytes (7.82 MB) in 2.72s
11:35PM INF no leaks found
suite: gitleaks took 3s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m6.510000s 0m0.190000s
--- shard 11 of 48 ---
==> shard 11 of 48: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-19-71, 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	3.936s
suite: browser.internal.harness took 6s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m1.660000s 0m1.180000s
--- shard 12 of 48 ---
==> shard 12 of 48: make browser.check~1of13
./hack/gotest.sh browser 'check~1of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 1 of 13 of ./check/ runs 5 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	68.860s
suite: browser.check~1of13 took 72s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m34.900000s 0m7.860000s
--- shard 13 of 48 ---
==> shard 13 of 48: make browser.check~2of13
./hack/gotest.sh browser 'check~2of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 2 of 13 of ./check/ runs 9 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	63.858s
suite: browser.check~2of13 took 67s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.880000s 0m7.240000s
--- shard 14 of 48 ---
==> shard 14 of 48: make browser.check~3of13
./hack/gotest.sh browser 'check~3of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 3 of 13 of ./check/ runs 10 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	67.937s
suite: browser.check~3of13 took 71s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.900000s 0m8.710000s
--- shard 15 of 48 ---
==> shard 15 of 48: make browser.check~4of13
./hack/gotest.sh browser 'check~4of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 4 of 13 of ./check/ runs 11 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	70.143s
suite: browser.check~4of13 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m36.010000s 0m10.560000s
--- shard 16 of 48 ---
==> shard 16 of 48: make browser.check~5of13
./hack/gotest.sh browser 'check~5of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 5 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	45.983s
suite: browser.check~5of13 took 49s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.650000s 0m6.190000s
--- shard 17 of 48 ---
==> shard 17 of 48: make browser.check~6of13
./hack/gotest.sh browser 'check~6of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 6 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	54.602s
suite: browser.check~6of13 took 58s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.200000s 0m8.710000s
--- shard 18 of 48 ---
==> shard 18 of 48: make browser.check~7of13
./hack/gotest.sh browser 'check~7of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 7 of 13 of ./check/ runs 16 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	47.427s
suite: browser.check~7of13 took 50s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.030000s 0m6.890000s
--- shard 19 of 48 ---
==> shard 19 of 48: make browser.check~8of13
./hack/gotest.sh browser 'check~8of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 8 of 13 of ./check/ runs 17 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	48.520s
suite: browser.check~8of13 took 51s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m17.130000s 0m8.000000s
--- shard 20 of 48 ---
==> shard 20 of 48: make browser.check~9of13
./hack/gotest.sh browser 'check~9of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 9 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	43.424s
suite: browser.check~9of13 took 46s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.940000s 0m9.690000s
--- shard 21 of 48 ---
==> shard 21 of 48: make browser.check~10of13
./hack/gotest.sh browser 'check~10of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 10 of 13 of ./check/ runs 17 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	58.303s
suite: browser.check~10of13 took 61s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m28.340000s 0m10.290000s
--- shard 22 of 48 ---
==> shard 22 of 48: make browser.check~11of13
./hack/gotest.sh browser 'check~11of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 11 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	45.492s
suite: browser.check~11of13 took 48s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.820000s 0m6.920000s
--- shard 23 of 48 ---
==> shard 23 of 48: make browser.check~12of13
./hack/gotest.sh browser 'check~12of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 12 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	51.818s
suite: browser.check~12of13 took 55s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.870000s 0m9.150000s
--- shard 24 of 48 ---
==> shard 24 of 48: make browser.check~13of13
./hack/gotest.sh browser 'check~13of13'
gotest: on ip-172-31-19-71, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 13 of 13 of ./check/ runs 18 of 189 browser tests
ok  	amadan.net/rastrillo/fiesta/check	44.856s
suite: browser.check~13of13 took 48s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.370000s 0m8.190000s
--- shard 25 of 48 ---
==> shard 25 of 48: make browser.internal.gallery~1of18
./hack/gotest.sh browser 'internal.gallery~1of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	34.996s
suite: browser.internal.gallery~1of18 took 38s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.720000s 0m2.850000s
--- shard 26 of 48 ---
==> shard 26 of 48: make browser.internal.gallery~2of18
./hack/gotest.sh browser 'internal.gallery~2of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	50.455s
suite: browser.internal.gallery~2of18 took 54s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.640000s 0m3.470000s
--- shard 27 of 48 ---
==> shard 27 of 48: make browser.internal.gallery~3of18
./hack/gotest.sh browser 'internal.gallery~3of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	58.640s
suite: browser.internal.gallery~3of18 took 62s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.530000s 0m3.950000s
--- shard 28 of 48 ---
==> shard 28 of 48: make browser.internal.gallery~4of18
./hack/gotest.sh browser 'internal.gallery~4of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	58.353s
suite: browser.internal.gallery~4of18 took 62s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.860000s 0m5.200000s
--- shard 29 of 48 ---
==> shard 29 of 48: make browser.internal.gallery~5of18
./hack/gotest.sh browser 'internal.gallery~5of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 5 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	60.500s
suite: browser.internal.gallery~5of18 took 64s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.110000s 0m4.300000s
--- shard 30 of 48 ---
==> shard 30 of 48: make browser.internal.gallery~6of18
./hack/gotest.sh browser 'internal.gallery~6of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 6 of 18 of ./internal/gallery/ runs 6 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	53.664s
suite: browser.internal.gallery~6of18 took 57s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.560000s 0m3.580000s
--- shard 31 of 48 ---
==> shard 31 of 48: make browser.internal.gallery~7of18
./hack/gotest.sh browser 'internal.gallery~7of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 7 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	54.481s
suite: browser.internal.gallery~7of18 took 57s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.030000s 0m2.460000s
--- shard 32 of 48 ---
==> shard 32 of 48: make browser.internal.gallery~8of18
./hack/gotest.sh browser 'internal.gallery~8of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 8 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	57.741s
suite: browser.internal.gallery~8of18 took 61s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.250000s 0m4.900000s
--- shard 33 of 48 ---
==> shard 33 of 48: make browser.internal.gallery~9of18
./hack/gotest.sh browser 'internal.gallery~9of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 9 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.350s
suite: browser.internal.gallery~9of18 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.220000s 0m6.760000s
--- shard 34 of 48 ---
==> shard 34 of 48: make browser.internal.gallery~10of18
./hack/gotest.sh browser 'internal.gallery~10of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 10 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	61.112s
suite: browser.internal.gallery~10of18 took 64s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.170000s 0m2.520000s
--- shard 35 of 48 ---
==> shard 35 of 48: make browser.internal.gallery~11of18
./hack/gotest.sh browser 'internal.gallery~11of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 11 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	61.838s
suite: browser.internal.gallery~11of18 took 65s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.160000s 0m2.490000s
--- shard 36 of 48 ---
==> shard 36 of 48: make browser.internal.gallery~12of18
./hack/gotest.sh browser 'internal.gallery~12of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 12 of 18 of ./internal/gallery/ runs 2 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.301s
suite: browser.internal.gallery~12of18 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.490000s 0m4.520000s
--- shard 37 of 48 ---
==> shard 37 of 48: make browser.internal.gallery~13of18
./hack/gotest.sh browser 'internal.gallery~13of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 13 of 18 of ./internal/gallery/ runs 3 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	64.900s
suite: browser.internal.gallery~13of18 took 68s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.170000s 0m5.120000s
--- shard 38 of 48 ---
==> shard 38 of 48: make browser.internal.gallery~14of18
./hack/gotest.sh browser 'internal.gallery~14of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 14 of 18 of ./internal/gallery/ runs 4 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.756s
suite: browser.internal.gallery~14of18 took 71s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.360000s 0m5.030000s
--- shard 39 of 48 ---
==> shard 39 of 48: make browser.internal.gallery~15of18
./hack/gotest.sh browser 'internal.gallery~15of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 15 of 18 of ./internal/gallery/ runs 4 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.232s
suite: browser.internal.gallery~15of18 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.320000s 0m5.290000s
--- shard 40 of 48 ---
==> shard 40 of 48: make browser.internal.gallery~16of18
./hack/gotest.sh browser 'internal.gallery~16of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 16 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	61.596s
suite: browser.internal.gallery~16of18 took 65s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.300000s 0m3.870000s
--- shard 41 of 48 ---
==> shard 41 of 48: make browser.internal.gallery~17of18
./hack/gotest.sh browser 'internal.gallery~17of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 17 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	64.206s
suite: browser.internal.gallery~17of18 took 67s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.730000s 0m5.630000s
--- shard 42 of 48 ---
==> shard 42 of 48: make browser.internal.gallery~18of18
./hack/gotest.sh browser 'internal.gallery~18of18'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 18 of 18 of ./internal/gallery/ runs 5 of 83 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	64.832s
suite: browser.internal.gallery~18of18 took 68s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.870000s 0m5.260000s
--- shard 43 of 48 ---
==> shard 43 of 48: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on ip-172-31-19-71, 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	31.579s
suite: browser.internal.gallery.history took 34s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.540000s 0m1.920000s
--- shard 44 of 48 ---
==> shard 44 of 48: make browser.internal.www~1of4
./hack/gotest.sh browser 'internal.www~1of4'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 4 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	66.566s
suite: browser.internal.www~1of4 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m30.280000s 0m2.030000s
--- shard 45 of 48 ---
==> shard 45 of 48: make browser.internal.www~2of4
./hack/gotest.sh browser 'internal.www~2of4'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	64.422s
suite: browser.internal.www~2of4 took 68s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.840000s 0m2.650000s
--- shard 46 of 48 ---
==> shard 46 of 48: make browser.internal.www~3of4
./hack/gotest.sh browser 'internal.www~3of4'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	78.384s
suite: browser.internal.www~3of4 took 82s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.060000s 0m2.890000s
--- shard 47 of 48 ---
==> shard 47 of 48: make browser.internal.www~4of4
./hack/gotest.sh browser 'internal.www~4of4'
gotest: on ip-172-31-19-71, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	73.432s
suite: browser.internal.www~4of4 took 77s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.480000s 0m2.820000s
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index de71d83..cd4b26f 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -156,10 +156,15 @@ browser internal/gallery TestTheGallerySwitchLandsOnTheNextFrame 2
browser internal/gallery TestTheGallerysOwnControlsAreExactlyTheFloor 2
browser internal/gallery TestThePhoneIndexAndTheWayBack 2
browser internal/gallery TestTheWayBackFromAShellDemoIsATarget 2
+browser internal/www TestAScrollAfterIdleWakesTheBunting 29
browser internal/www TestTheLandingPassesAxe 16
+browser internal/www TestAShakeMovesTheBuntingMoreThanAScroll 15
+browser internal/www TestTheBuntingFollowsTheScrollBack 15
browser internal/www TestTheLanguageMenuOpensOnAPhone 14
browser internal/www TestTheLandingHasNoHorizontalScrollAt320 13
browser internal/www TestTheSampleFormsStayOnThePage 10
+browser internal/www TestTheBuntingNeverLeavesItsRow 9
+browser internal/www TestTheBuntingHangsTheSameAfterAShake 9
browser internal/www TestArabicReadsRightToLeft 9
browser internal/www TestCopy 9
browser internal/www TestTheSchemeMenuMarksTheRestoredScheme 9
@@ -168,6 +173,7 @@ browser internal/www TestABrokenStoredThemeFallsBackToDay 8
browser internal/www TestTheBuntingMovesOnlyWhenMotionIsAllowed 8
browser internal/www TestTheLandingReadsWithNoScript 8
browser internal/www TestTheSiteServesFromItsOwnOrigin 8
+browser internal/www TestReducedMotionIgnoresScrolling 7
browser internal/www TestEverySamplePaneAndCourseSwitches 7
browser internal/www TestTheBuntingFollowsTheTheme 7
browser internal/www TestTheSampleSwitchDrags 7
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go
index 2688c5e..23cd65c 100644
--- a/internal/www/landing_browser_test.go
+++ b/internal/www/landing_browser_test.go
@@ -14,6 +14,7 @@ import (
"fmt"
"image"
imagepng "image/png"
+ "math"
"net/http"
"strings"
"testing"
@@ -1159,6 +1160,314 @@ func TestTheStillBuntingMatchesTheMovingOne(t *testing.T) {
rig.Screen("#headline", "the page with the still bunting")
}
+// motionJS gives a drive what it needs to scroll the page as a reader
+// does and watch the canvas while it happens: rest() keeps the
+// canvas's pixels as they are now, off() is the percentage of them
+// that have plainly changed since (a channel moved by more than 48, so
+// the cloth moved rather than turned a shade in the light: the
+// slightest tilt reshades a whole pennant), glide(to, ms) scrolls
+// there at an even speed, one step a frame, as a trackpad does, and
+// shake(n, by, ms) is n quick trips down by that much and back, each
+// leg taking ms. The page's own scroll-behaviour is overridden: a
+// smooth scroll would hand the timing to the browser.
+const motionJS = `window.siteMotion = (() => {
+ const canvas = () => document.querySelector("#bunting canvas"), frame = () => new Promise(r => requestAnimationFrame(r));
+ const pixels = () => { const c = canvas(); return c.getContext("2d").getImageData(0, 0, c.width, c.height).data; };
+ let base = null;
+ const m = {
+ frame,
+ rest() { base = pixels(); return true; },
+ off() {
+ const d = pixels();
+ let n = 0;
+ for (let i = 0; i < d.length; i += 4) if (Math.max(Math.abs(d[i] - base[i]), Math.abs(d[i + 1] - base[i + 1]), Math.abs(d[i + 2] - base[i + 2]), Math.abs(d[i + 3] - base[i + 3])) > 48) n++;
+ return 100 * n / (d.length / 4);
+ },
+ async glide(to, ms, each) {
+ const from = scrollY, t0 = performance.now();
+ for (;;) {
+ await frame();
+ const u = Math.min(1, (performance.now() - t0) / ms);
+ scrollTo({ top: from + (to - from) * u, behavior: "instant" });
+ if (each) each();
+ if (u >= 1) return;
+ }
+ },
+ async shake(n, by, ms, each) {
+ const home = scrollY;
+ for (let i = 0; i < n; i++) { await m.glide(home + by, ms, each); await m.glide(home, ms, each); }
+ },
+ // motion integrates off() over the ms from now, in percent
+ // seconds: how much of the canvas moved from rest, and for how
+ // long, in one number. Each sample stands for the time since the
+ // last, and none counts past the deadline, even mid-gesture, so a
+ // slower frame rate samples less often but weighs each sample
+ // more, and the number does not depend on it.
+ async motion(ms, during) {
+ const t0 = performance.now(), end = t0 + ms;
+ let sum = 0, last = t0;
+ const each = () => {
+ const now = Math.min(performance.now(), end);
+ if (now > last) { sum += m.off() * (now - last) / 1000; last = now; }
+ };
+ if (during) await during(each);
+ while (performance.now() < end) { await frame(); each(); }
+ return sum;
+ },
+ };
+ return m;
+})(); true`
+
+// buntingProbe is what siteBuntingProbe reports: the cloth's extremes
+// since the page opened, in the SVG's units, and whether the frame
+// loop is running.
+type buntingProbe struct {
+ MinY, MaxY, MinX, MaxX, MaxZ, Stretch float64
+ Height, From, To float64
+ Finite, Running bool
+ Builds int
+}
+
+func probeBunting(t *testing.T, ctx context.Context) buntingProbe {
+ t.Helper()
+ var p buntingProbe
+ at(t, ctx, `window.siteBuntingProbe()`, &p)
+ return p
+}
+
+// openStill opens the landing page with the breeze held off and the
+// probe on, and waits for the canvas to hang still with its loop
+// asleep: the cloth at rest, so whatever moves it next is the drive's.
+func openStill(t *testing.T, ctx context.Context, rig *harness.Rig) {
+ t.Helper()
+ onNewDocument(t, ctx, `window.siteBuntingWindless = true; window.siteBuntingProbe = true;`)
+ open(t, ctx, rig, 1280, 900)
+ waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
+ settle(t, ctx, "the canvas before any scrolling", 10*time.Second)
+ mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
+}
+
+// settle waits for the canvas's frame loop to go to sleep, which it
+// does once the cloth is still, and fails after within.
+func settle(t *testing.T, ctx context.Context, what string, within time.Duration) time.Duration {
+ t.Helper()
+ start := time.Now()
+ for {
+ if p := probeBunting(t, ctx); !p.Running {
+ return time.Since(start)
+ }
+ if time.Since(start) > within {
+ t.Fatalf("%s: the cloth was still moving %v later", what, within)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+// TestTheBuntingFollowsTheScrollBack: a reader scrolls the row off
+// screen, reads a little, and scrolls back up quickly. The air the
+// page moved through is still moving when the row comes back, so the
+// cloth lifts at once: within 300 ms of the row's return the canvas
+// is well away from its rest. Then it settles, and the loop sleeps,
+// within a few seconds. The breeze is held off so the scroll is the
+// only thing moving the cloth. Settled means the loop has gone to
+// sleep and the canvas is back within 1 of its rest.
+func TestTheBuntingFollowsTheScrollBack(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ openStill(t, ctx, rig)
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
+ var back struct{ Peak, Returned float64 }
+ at(t, ctx, `(async () => {
+ const m = siteMotion, row = document.getElementById("bunting");
+ await m.glide(700, 250);
+ await new Promise(r => setTimeout(r, 1200));
+ let returned = -1, peak = 0;
+ const each = () => {
+ if (returned < 0 && row.getBoundingClientRect().bottom > 0) returned = performance.now();
+ if (returned >= 0 && performance.now() - returned <= 300) peak = Math.max(peak, m.off());
+ };
+ await m.glide(0, 250, each);
+ while (performance.now() - returned <= 300) { await m.frame(); each(); }
+ return JSON.stringify({Peak: peak, Returned: returned});
+})()`, &back, awaitPromise)
+ if back.Returned < 0 {
+ t.Fatal("the row never came back on screen")
+ }
+ took := settle(t, ctx, "the cloth after the scroll back", 8*time.Second)
+ var after float64
+ at(t, ctx, `JSON.stringify(siteMotion.off())`, &after)
+ t.Logf("within 300 ms of the row's return %.2f%% of the canvas had moved from rest; it settled %v later, %.2f%% off rest", back.Peak, took.Round(10*time.Millisecond), after)
+ if back.Peak < buntingScrollBack {
+ t.Errorf("within 300 ms of the row coming back at most %.2f%% of the canvas had moved from rest; want at least %.0f%%: the scroll back did not reach the cloth", back.Peak, buntingScrollBack)
+ }
+ if after > 1 {
+ t.Errorf("the settled canvas is %.2f%% off its rest before the scroll; want under 1%%", after)
+ }
+}
+
+// buntingScrollBack is the percentage of the canvas, in motionJS's
+// off(), that must move from rest within 300 ms of a quick scroll
+// back: before the cloth felt the air it was under 1, with it near 10.
+const buntingScrollBack = 3.0
+
+// TestAShakeMovesTheBuntingMoreThanAScroll: near the top of the page,
+// three quick trips 80 px down and back, 100 ms a leg, are the frame
+// jolting, and the cloth must dance; one slow trip down the same 80 px
+// and back, 800 ms a leg, is a gentle current, and the cloth only
+// sways. Each is measured over the same three seconds from its start,
+// with the breeze held off and the cloth at rest before each. 80 px
+// keeps the row on screen; much further and it leaves, and the loop
+// sleeps.
+func TestAShakeMovesTheBuntingMoreThanAScroll(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ openStill(t, ctx, rig)
+ var slow, shake float64
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
+ at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(1, 80, 800, each)).then(JSON.stringify)`, &slow, awaitPromise)
+ settle(t, ctx, "the cloth after the slow scroll", 8*time.Second)
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
+ at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(3, 80, 100, each)).then(JSON.stringify)`, &shake, awaitPromise)
+ t.Logf("motion over three seconds, off() integrated over time: the slow scroll %.2f, the shake %.2f %%·s (%.1f times)", slow, shake, shake/max(slow, 1e-9))
+ if slow <= 0 {
+ t.Errorf("the slow scroll did not move the cloth at all")
+ }
+ if shake < 1.8*slow {
+ t.Errorf("the shake moved the canvas %.2f %%·s and the slow scroll %.2f; want the shake at least 1.8 times the scroll", shake, slow)
+ }
+}
+
+// TestTheBuntingNeverLeavesItsRow: a violent shake, far faster than a
+// hand, with the breeze blowing, never throws the cloth out of the
+// canvas or tears it. The probe reports every point's extremes from
+// the page opening to the end of the shake: each one finite, inside
+// the canvas top to bottom and between the end tacks, and no link
+// stretched past half again its length.
+func TestTheBuntingNeverLeavesItsRow(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ onNewDocument(t, ctx, `window.siteBuntingProbe = true;`)
+ open(t, ctx, rig, 1280, 900)
+ waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
+ mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.motion(1500, each => siteMotion.shake(25, 90, 1, each))`, nil, awaitPromise))
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.motion(1500, each => siteMotion.glide(3000, 1).then(() => siteMotion.glide(0, 1)).then(() => siteMotion.shake(10, 120, 1, each)))`, nil, awaitPromise))
+ p := probeBunting(t, ctx)
+ t.Logf("extremes: y %.1f to %.1f of %.1f, x %.1f to %.1f between %.1f and %.1f, |z| %.1f, stretch %.3f", p.MinY, p.MaxY, p.Height, p.MinX, p.MaxX, p.From, p.To, p.MaxZ, p.Stretch)
+ if !p.Finite {
+ t.Fatal("a point of the cloth went to NaN or infinity")
+ }
+ if p.MinY < 0 || p.MaxY > p.Height {
+ t.Errorf("the cloth reached y %.1f to %.1f; the canvas shows 0 to %.1f", p.MinY, p.MaxY, p.Height)
+ }
+ if p.MinX < p.From || p.MaxX > p.To {
+ t.Errorf("the cloth reached x %.1f to %.1f; the rope's end tacks are at %.1f and %.1f", p.MinX, p.MaxX, p.From, p.To)
+ }
+ if p.Stretch > 1.5 {
+ t.Errorf("a link stretched to %.2f times its length; the cloth tore", p.Stretch)
+ }
+}
+
+// TestAScrollAfterIdleWakesTheBunting: a reader leaves the page for
+// twenty seconds, then turns the wheel once, 80 px, with the row on
+// screen. One scroll event that long after the last is a slow scroll
+// by its average speed, but the air it moves is real, so the cloth
+// must wake and lift.
+func TestAScrollAfterIdleWakesTheBunting(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ openStill(t, ctx, rig)
+ mustRun(t, ctx, chromedp.Evaluate(`scrollTo({top: 80, behavior: "instant"}); true`, nil))
+ mustRun(t, ctx, chromedp.Sleep(20*time.Second))
+ settle(t, ctx, "the cloth after twenty idle seconds", time.Second)
+ var peak float64
+ at(t, ctx, `(async () => {
+ const m = siteMotion;
+ m.rest();
+ scrollTo({top: 0, behavior: "instant"});
+ let peak = 0;
+ const t0 = performance.now();
+ while (performance.now() - t0 < 1000) { await m.frame(); peak = Math.max(peak, m.off()); }
+ return JSON.stringify(peak);
+})()`, &peak, awaitPromise)
+ t.Logf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas within a second", peak)
+ if peak < 1 {
+ t.Errorf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas; want at least 1%%: the cloth never woke", peak)
+ }
+}
+
+// TestTheBuntingHangsTheSameAfterAShake: a rebuild (a resize, or
+// motion allowed again) hangs the row with the calm pre-roll, which
+// must be the rest pose whatever the cloth was doing: the flutter a
+// shake leaves behind must not leak into it, or the first frame after
+// the rebuild is not the still SVG. The viewport grows a pixel taller
+// right after a shake, which rebuilds the same row; the pose it hangs
+// is held to the one hung when the page opened.
+func TestTheBuntingHangsTheSameAfterAShake(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ openStill(t, ctx, rig)
+ var before, after []float64
+ at(t, ctx, `window.siteBuntingProbe.hung()`, &before)
+ builds := probeBunting(t, ctx).Builds
+ mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.shake(3, 80, 100)`, nil, awaitPromise))
+ mustRun(t, ctx, chromedp.EmulateViewport(1280, 901))
+ deadline := time.Now().Add(5 * time.Second)
+ for probeBunting(t, ctx).Builds == builds {
+ if time.Now().After(deadline) {
+ t.Fatal("resizing the viewport never rebuilt the row")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ at(t, ctx, `window.siteBuntingProbe.hung()`, &after)
+ if len(before) == 0 || len(before) != len(after) {
+ t.Fatalf("the row hung %d coordinates before the shake and %d after; the rebuild was not the same row", len(before), len(after))
+ }
+ worst := 0.0
+ for i := range before {
+ worst = max(worst, math.Abs(after[i]-before[i]))
+ }
+ t.Logf("the pose hung after the shake is at most %.4f units from the one hung before", worst)
+ if worst > 0.01 {
+ t.Errorf("the pose hung after a shake is %.2f units from the rest pose; want under 0.01: the shake leaked into the calm pre-roll", worst)
+ }
+}
+
+// TestReducedMotionIgnoresScrolling: under reduced motion there is no
+// canvas, and scrolling and shaking the page leave the still SVG
+// exactly as it was.
+func TestReducedMotionIgnoresScrolling(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ defer func() {
+ rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second)
+ defer rcancel()
+ _ = chromedp.Run(rctx, emulation.SetEmulatedMedia())
+ }()
+ reduceMotion(t, ctx, true)
+ open(t, ctx, rig, 1280, 900)
+ still := func(b bunting) bool { return !b.Canvas && b.SvgShown }
+ waitBunting(t, ctx, "the still SVG under reduced motion", still)
+ var strip struct{ Top, Height, Width float64 }
+ at(t, ctx, `(() => { const r = document.getElementById("bunting").getBoundingClientRect(); return JSON.stringify({Top: r.top, Height: r.height, Width: r.width}); })()`, &strip)
+ before := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
+ mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
+ mustRun(t, ctx, chromedp.Evaluate(`(async () => { await siteMotion.glide(700, 250); await siteMotion.glide(0, 250); await siteMotion.shake(3, 60, 70); await new Promise(r => setTimeout(r, 300)); return true; })()`, nil, awaitPromise))
+ if b := readBunting(t, ctx); !still(b) {
+ t.Errorf("after scrolling under reduced motion the canvas is there=%v and the SVG shown=%v; want the SVG alone", b.Canvas, b.SvgShown)
+ }
+ after := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
+ if d := meanApart(before, after); d != 0 {
+ t.Errorf("the still SVG changed by %.2f per pixel after scrolling under reduced motion; want no change", d)
+ }
+}
+
// withoutAnchorSupport is what anchor.js's feature test sees in a
// browser without anchor positioning: CSS.supports says no to it.
const withoutAnchorSupport = `(() => { const s = CSS.supports.bind(CSS); CSS.supports = (...a) => !/position-area|position-try/.test(a.join(" ")) && s(...a); })()`
diff --git a/site/static/bunting.js b/site/static/bunting.js
index 90dd26f..23790a8 100644
--- a/site/static/bunting.js
+++ b/site/static/bunting.js
@@ -1,9 +1,10 @@
/* bunting.js: the landing page's bunting, hung on a canvas over the
still SVG in the markup. A Verlet rope in swags of six pennants,
- taut between tacks, moved by a breeze, by gusts when the page
- scrolls, by puffs from the pointer, and by a hand brushing the
- cloth; shaded by tilt; one animation frame loop that sleeps when the
- row is off screen, the tab is hidden, or nothing moves.
+ taut between tacks, moved by a breeze, by the air the page moves
+ through when it scrolls, by a jolt when the page is shaken, by
+ puffs from the pointer, and by a hand brushing the cloth; shaded by
+ tilt; one animation frame loop that sleeps when the row is off
+ screen, the tab is hidden, or nothing moves.
Each pennant is a triangle of cloth: a triangular lattice of points
narrowing to one at the tip. Triangles are rigid in their plane, so
@@ -39,11 +40,45 @@
var R = 58, KC_X = 11, KC_Y = 5, KC_Z = 5.5, DRAG = 0.12, CONTACT_PAD = 12, SP_FULL = 2400, touch = null;
var D0 = 200, CUT = 600, KW = 1.15, TAU = 0.07, RIPPLE = 0.0007, PUFF_MS = 160, MAX_PUFFS = 6, docTop = 0, docLeft = 0;
var IDLE = 8000, CALM = 1500;
+ // Scrolling, with the page as the frame the bunting hangs in. The
+ // air: AIR_TAU is how long, in seconds, the air the page moved
+ // through keeps moving against it, short enough that a flick has
+ // settled in a few seconds and long enough to outlast a scroll back
+ // up to the row. AIR_MAX and SCROLL_MAX (screen pixels a second) and
+ // LEAP (pixels an event) bound what one flick or one jump can count
+ // for. AIR_REST (pixels a second) is the air too weak to feel, which
+ // lets the loop sleep. The cloth feels the air through AIR_SAT (world
+ // units a second, where its push saturates), AIR_Y (its push along
+ // the page, per unit a second) and AIR_Z (its billow and flutter, as
+ // the breeze gives, full at about AIR_SOFT). Rising air billows the
+ // cloth and falling air AIR_DOWN as much: a pennant hanging into the
+ // air is unstable and lifts, one hanging with it just trails.
+ //
+ // The shake: FRAME_W is the stiffness (per second) of a critically
+ // damped follower of scrollY whose acceleration jolts every free
+ // point, scaled by JOLT and bounded by JOLT_MAX (world units a
+ // second squared, under G and a quarter). A jolt under JOLT_DEAD is
+ // taken up by the cloth's own weight; beyond it the cloth goes slack
+ // and buckles out of the page (JOLT_Z) and along the row (JOLT_X),
+ // and the jolts pile up into jig, a flutter of JIG_FLUT that dies
+ // away over JIG_TAU seconds.
+ var AIR_TAU = 0.8, AIR_MAX = 2500, SCROLL_MAX = 4000, LEAP = 400, AIR_SAT = 900, AIR_Y = 0.65, AIR_Z = 1.6, AIR_SOFT = 300, AIR_DOWN = 0.25, AIR_REST = 20;
+ var FRAME_W = 24, JOLT = 0.08, JOLT_MAX = 800, JOLT_DEAD = 200, JOLT_Z = 1.5, JOLT_X = 0.6, JIG_TAU = 0.6, JIG_GAIN = 8, JIG_MAX = 1.5, JIG_FLUT = 700;
+ var air = 0, airT = performance.now(), frameY = 0, frameV = 0, jig = 0, scrollV = 0, scrollT = -1e9;
var lastInput = performance.now(), breeze = 1, motion = 1;
+ // Two hooks for the tests, read once, before the first frame.
+ // siteBuntingWindless: the breeze never blows, so the cloth hangs at
+ // rest until something else moves it and a drive sees that alone.
+ // siteBuntingProbe: replaced by a function that reports how far the
+ // cloth has strayed since the page opened, for holding a violent
+ // shake to the row; its hung() is the pose the last rebuild hung
+ // before its first frame, for holding that to the rest pose.
+ var windless = !!window.siteBuntingWindless, probe = window.siteBuntingProbe ? { minY: Infinity, maxY: -Infinity, minX: Infinity, maxX: -Infinity, maxZ: 0, stretch: 0, finite: true, hung: [], builds: 0 } : null;
+ if (windless) breeze = 0;
var colours = Array.prototype.map.call(svg.querySelectorAll(".site-flag"), function (g) { return +g.getAttribute("data-c"); });
var ncol = 6, tex = [], stringColour = "currentColor";
var X, Y, Z, PX, PY, PZ, INV, DEP, COL, U, V, cons, flags = [], ropeN = 0, scale = 1, offset = 0, dpr = 1;
- var pending = null, puffs = [], lastPuff = 0, PAX = 0, PAY = 0, PAZ = 0, gust = 0, t0 = performance.now();
+ var pending = null, puffs = [], lastPuff = 0, PAX = 0, PAY = 0, PAZ = 0, t0 = performance.now();
// One texture per colour: the pennant painted from SVG (triangle,
// hem tape, a stitch line), then a plain weave laid over it: fine
@@ -182,7 +217,11 @@
if (i < nj2 - 2) c.push(a, rj[i + 2], 2 * DX, j ? BEND : HEM);
}
}
- return { cx: cx, ids: ids, tris: tris, tie: [t[0], rows[0][0], t[1], rows[0][NY - 1]], c: colours[k] % ncol, seed: k * 1.7 };
+ // side and lean are which way the pennant buckles when a jolt
+ // slackens it, out of the page and along the row: a hash of k, so
+ // neighbours differ and the same pennant always goes the same way.
+ return { cx: cx, ids: ids, tris: tris, tie: [t[0], rows[0][0], t[1], rows[0][NY - 1]], c: colours[k] % ncol, seed: k * 1.7,
+ side: Math.abs(Math.sin(k * 78.233) * 43758.5453) % 1 < 0.5 ? -1 : 1, lean: Math.abs(Math.sin(k * 12.9898) * 43758.5453) % 1 < 0.5 ? -1 : 1 };
});
cons = new Float32Array(c);
// Hang the row before the first frame, so the page never opens on
@@ -191,29 +230,59 @@
// the same constants and passes), so the first frame is the still
// picture, and the wind takes it from there.
for (i = 0; i < 120; i++) step(i * DT, true);
+ if (probe) { probe.hung = [].concat(Array.from(X), Array.from(Y), Array.from(Z)); probe.builds++; }
+ }
+
+ // airAt is the air the page moved through, as it is at now: screen
+ // pixels a second, down positive, relative to the page. It decays in
+ // real time whether or not the loop is running, so a row that comes
+ // back on screen feels what is left of it, never a replay.
+ function airAt(now) {
+ return air * Math.exp(-(now - airT) / 1000 / AIR_TAU);
}
// step advances the cloth one DT. calm is the hang before the first
- // frame: no breeze, no gust, no puff, no hand on the cloth and no
- // sway on the rope, so it is the same hang every time.
+ // frame: no breeze, no air, no jolt, no flutter left from a shake, no
+ // puff, no hand on the cloth and no sway on the rope, so it is the
+ // same hang every time, a rebuild after a shake included, and the
+ // still SVG drawn from it is the rest pose.
function step(t, calm) {
var dt2 = DT * DT, now = performance.now(), i, f, q;
- var tn = now / 1000;
+ var tn = now / 1000, airY = 0, airB = 0, jy = 0, snap = 0;
if (!calm) {
if (pending && (!pending.hold || now - lastPuff >= PUFF_MS)) {
pending.t = tn; lastPuff = now; puffs.push(pending); pending = null;
if (puffs.length > MAX_PUFFS) puffs.shift();
}
while (puffs.length && tn - puffs[0].t > 1.5) puffs.shift();
- gust *= 0.97;
- breeze += ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02;
+ breeze = windless ? 0 : breeze + ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02;
+ // The current: the air moving against the page, saturating so a
+ // flick lifts the cloth without throwing it over the rope (AIR_Y
+ // times AIR_SAT stays under G).
+ var aw = airAt(now), u = Math.sign(aw) * Math.max(0, Math.abs(aw) - AIR_REST) / scale, us = AIR_SAT * Math.tanh(u / AIR_SAT);
+ airY = AIR_Y * us; airB = AIR_Z * Math.tanh((us < 0 ? -us : AIR_DOWN * us) / AIR_SOFT);
+ // The shake: the frame follows the page on a critically damped
+ // spring, so a wheel's jumps and a trackpad's stream give the
+ // same smooth motion, and its acceleration is an inertial force
+ // on every free point, the opposite way to the page's. scrollY
+ // rising moves the page up, so the force is along the follower's
+ // own acceleration.
+ var fa = FRAME_W * FRAME_W * (scrollY - frameY) - 2 * FRAME_W * frameV;
+ frameV += fa * DT; frameY += frameV * DT;
+ jy = Math.max(-JOLT_MAX, Math.min(JOLT_MAX, JOLT * fa / scale));
+ // snap is what the cloth's weight cannot take up: jitter in the
+ // scroll events and the start of a slow scroll stay under it. jig
+ // is the flutter the snaps leave: each adds to it and it dies
+ // away, so repeated reversals build a dance.
+ snap = Math.max(0, Math.abs(jy) - JOLT_DEAD);
+ jig = Math.min(JIG_MAX, jig * Math.exp(-DT / JIG_TAU) + JIG_GAIN * DT * snap / JOLT_MAX);
}
var p = !calm && touch && now - touch.t < 90 ? touch : null;
for (f = 0; f < flags.length; f++) {
var F = flags[f], cx = F.cx;
// The breeze: two slow waves travelling different ways along the
// row, so neighbours move together but never in lockstep.
- var b = calm ? 0 : (0.5 + 0.5 * Math.sin(t * 0.37 + cx * 0.0021) * Math.sin(t * 0.71 - cx * 0.0037 + 1.3)) * breeze + gust;
+ var b = calm ? 0 : (0.5 + 0.5 * Math.sin(t * 0.37 + cx * 0.0021) * Math.sin(t * 0.71 - cx * 0.0037 + 1.3)) * breeze + airB;
var wz = 200 * b, wx = 50 * b * Math.sin(t * 0.23 + cx * 0.001);
if (calm) PAX = PAY = PAZ = 0; else puffAt(cx, tn);
for (q = 0; q < F.ids.length; q++) {
@@ -222,12 +291,14 @@
// sides, adds a quicker flutter of its own.
var flut = Math.sin(t * 7.3 + COL[id] * 0.9 + depth * 3.6 + F.seed) * d2
+ 0.7 * Math.sin(t * 11.4 + COL[id] * 0.5 + F.seed * 2.3) * d2 * d2;
- integrate(id, (wx * (0.4 + 0.6 * depth) + PAX * dep) / MASS, PAY * dep / MASS,
- (wz * (0.3 + 0.7 * depth) + 130 * flut * b + PAZ * dep) / MASS, dt2);
+ integrate(id, (wx * (0.4 + 0.6 * depth) + PAX * dep) / MASS + JOLT_X * snap * F.lean * d2, (PAY + airY) * dep / MASS + jy,
+ (wz * (0.3 + 0.7 * depth) + (130 * b + (calm ? 0 : JIG_FLUT * jig)) * flut + PAZ * dep) / MASS + JOLT_Z * snap * F.side * (0.2 + 0.8 * d2), dt2);
if (p) contact(id, p);
}
}
- for (i = 0; i < ropeN; i++) if (INV[i]) integrate(i, 0, 0, calm ? 0 : 30 * Math.sin(t * 0.5 + i * 0.05) / MASS, dt2);
+ // The rope feels the current more weakly than the cloth, and the
+ // jolt in full: it is inertial.
+ for (i = 0; i < ropeN; i++) if (INV[i]) integrate(i, 0, 0.4 * airY / MASS + jy, calm ? 0 : 30 * Math.sin(t * 0.5 + i * 0.05) / MASS, dt2);
for (var it = 0; it < ITER; it++) {
for (var k = 0; k < cons.length; k += 4) {
var a = cons[k], c = cons[k + 1], wa = INV[a], wc = INV[c], ws = wa + wc;
@@ -366,7 +437,7 @@
// live is whether the canvas is on the page: false under reduced
// motion. running is whether the frame loop is going.
- var live = false, running = false, onScreen = true, acc = 0, lastT = 0, drawn = false;
+ var live = false, running = false, onScreen = true, acc = 0, lastT = 0, drawn = false, raf = 0;
function frame(now) {
if (!running || !live) return;
if (!onScreen || document.hidden) { running = false; return; }
@@ -374,19 +445,54 @@
var t = (now - t0) / 1000, n = 0;
motion = 0;
while (acc >= DT && n < 3) { step(t); acc -= DT; n++; }
- // Still cloth and no breeze left: draw this last frame and sleep.
- var still = n > 0 && !pending && !(touch && now - touch.t < 90) && breeze < 0.01 && gust < 0.01 && motion < 1e-4 && !(puffs.length && now / 1000 - puffs[puffs.length - 1].t < 0.6);
+ if (probe && n) record();
+ // Still cloth, no breeze, no current and the page at rest: draw
+ // this last frame and sleep.
+ var still = n > 0 && !pending && !(touch && now - touch.t < 90) && breeze < 0.01 && Math.abs(airAt(now)) < AIR_REST && jig < 0.01 &&
+ Math.abs(frameV) < 1 && Math.abs(scrollY - frameY) < 0.5 && motion < 1e-4 && !(puffs.length && now / 1000 - puffs[puffs.length - 1].t < 0.6);
if (n && (still || now - lastInput < CALM || (drawn = !drawn))) draw();
if (still) { running = false; return; }
- requestAnimationFrame(frame);
+ raf = requestAnimationFrame(frame);
+ }
+ // record widens the probe's extremes to this frame's cloth: every
+ // point's place, and the worst stretch of any link over its length.
+ function record() {
+ for (var i = 0; i < X.length; i++) {
+ if (!isFinite(X[i] + Y[i] + Z[i])) probe.finite = false;
+ probe.minY = Math.min(probe.minY, Y[i]); probe.maxY = Math.max(probe.maxY, Y[i]);
+ probe.minX = Math.min(probe.minX, X[i]); probe.maxX = Math.max(probe.maxX, X[i]);
+ probe.maxZ = Math.max(probe.maxZ, Math.abs(Z[i]));
+ }
+ for (var k = 0; k < cons.length; k += 4) {
+ var a = cons[k], c = cons[k + 1];
+ probe.stretch = Math.max(probe.stretch, Math.hypot(X[c] - X[a], Y[c] - Y[a], Z[c] - Z[a]) / cons[k + 2]);
+ }
}
+ if (probe) window.siteBuntingProbe = function () {
+ return JSON.stringify({ MinY: probe.minY, MaxY: probe.maxY, MinX: probe.minX, MaxX: probe.maxX, MaxZ: probe.maxZ, Stretch: probe.stretch, Finite: probe.finite,
+ Height: canvas.getBoundingClientRect().height / scale, From: X ? X[0] : 0, To: X ? X[ropeN - 1] : 0, Running: running, Builds: probe.builds });
+ };
+ if (probe) window.siteBuntingProbe.hung = function () { return JSON.stringify(probe.hung); };
+
// window.siteBuntingStill is a hook for the tests: set before this
// runs, the canvas is hung at rest and never moves, so a drive can
// hold the still SVG to the first frame.
function start() {
if (running || !live || !onScreen || document.hidden || !tex.length || window.siteBuntingStill) return;
running = true; lastT = performance.now(); acc = 0;
- requestAnimationFrame(frame);
+ // The frame picks up the page where it is, moving as it was, so
+ // waking mid-scroll is not a jolt.
+ frameY = scrollY; frameV = lastT - scrollT < 100 ? scrollV : 0; jig = 0;
+ raf = requestAnimationFrame(frame);
+ }
+ // A hidden tab's queued frame may be suspended rather than run, which
+ // would leave running set, so start() on the way back would return
+ // early and the loop would pick up the follower and jig where they
+ // were, a stale shake. Cancelling it and clearing running on the way
+ // out makes the way back a fresh start().
+ function pause() {
+ cancelAnimationFrame(raf);
+ running = false;
}
var lx = null, ly = 0, lt = 0;
@@ -409,16 +515,28 @@
}
lx = wx; ly = wy; lt = now;
}, { passive: true });
+ // A scroll moves the air against the page: each event adds its
+ // distance over AIR_TAU, so a steady scroll's air comes to its speed
+ // and the sum does not depend on how many events carry it. A jump (a
+ // link to an anchor, the End key) is not a hand moving the page, so
+ // no more than LEAP, and no faster than SCROLL_MAX, counts.
var sy = scrollY, st = performance.now();
addEventListener("scroll", function () {
- var now = performance.now(), v = Math.abs(scrollY - sy) / Math.max(now - st, 8);
+ var now = performance.now(), dt = Math.max(now - st, 8), lim = Math.min(LEAP, SCROLL_MAX * dt / 1000);
+ var d = Math.max(-lim, Math.min(lim, scrollY - sy)), v = d / dt * 1000;
sy = scrollY; st = now;
- gust = Math.min(1.2, gust + v * 0.25);
- if (v > 0.05) { lastInput = now; start(); }
+ air = Math.max(-AIR_MAX, Math.min(AIR_MAX, airAt(now) + d / AIR_TAU)); airT = now;
+ scrollV = now - scrollT < 100 ? (scrollV + v) / 2 : v; scrollT = now;
+ // Wake on the air as well as the speed: v is averaged since the
+ // last event, so one wheel turn after a long idle reads as slow
+ // while the air it moved is not.
+ if (Math.abs(v) > 50 || Math.abs(air) > AIR_REST) { lastInput = now; start(); }
}, { passive: true });
new IntersectionObserver(function (es) { onScreen = es[0].isIntersecting; if (onScreen) start(); }).observe(row);
- document.addEventListener("visibilitychange", start);
+ document.addEventListener("visibilitychange", function () { if (document.hidden) pause(); else start(); });
+ addEventListener("pagehide", pause);
+ addEventListener("pageshow", start);
var rz;
addEventListener("resize", function () {
clearTimeout(rz);
@@ -454,7 +572,7 @@
draw();
row.classList.add("site-bunting-live");
lastInput = performance.now();
- breeze = 1;
+ breeze = windless ? 0 : 1;
start();
});
}