rastrillo / fiesta Public

Move the bunting with the air the page scrolls through, and jolt it when the page is shaken

The old scroll effect was a direction-blind gust in z that froze while the row was off screen and barely showed: a quick scroll back up moved under 1% of the canvas within 300 ms of the row returning, and three quick trips down and back moved it no more than one slow trip.

The page is now the frame the bunting hangs in. Each scroll event adds its distance over AIR_TAU (0.8 s) to the air moving against the page, which decays in real time whether or not the loop runs, so a row that comes back on screen feels what is left at once and never a replay. Rising air billows the cloth and lifts it, falling air only tugs it straight, as a pennant hanging into the air is unstable and one hanging with it trails. A critically damped follower of scrollY smooths a wheel's jumps and a trackpad's stream alike; its acceleration is an inertial force on every free point, bounded under G and a quarter, and what the cloth's weight cannot take up buckles each pennant out of the page and along the row and piles up into a flutter that dies away, so repeated reversals make it dance. The old gust is gone rather than kept beside the air, so two scroll effects never fight.

The rest pose is untouched: the calm pre-roll has no air and no jolt, so the still SVG still equals the first frame.
Human

Prompt

Can we also have the flags on the homepage react to scrolling? Like when I scroll back up they should react to the 'current' created by the scrolling or if I scroll up and down quickly to 'shake' the page they should react.

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.

Summary

bunting.js: the current and the shake replace the gust. Four drives: the scroll back reaches the cloth and settles, a shake moves it more than a slow scroll, a violent shake keeps it in its row, reduced motion ignores scrolling. Weights for them in hack/test-weights.txt.
Agent notes
Red against the previous commit by assertion: the scroll back moved 0.82% of the canvas (want 3%, now about 9%), the shake 1.1 times the slow scroll (want 1.8, now about 2.4). The row-bounds and reduced-motion drives pass on both: they hold properties today's code already had. Weights measured on docs-t1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a6ea97c29b622e1b21584172dee22ce06323c24a parent 176c5e5
3 files changed, +328 −19
  • hack/test-weights.txt +4 −0
  • internal/www/landing_browser_test.go +233 −0
  • site/static/bunting.js +91 −19

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.183s
=== 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.03s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.029s
=== history ===
history: 7 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.780s
ok  	amadan.net/rastrillo/fiesta/check	6.931s
ok  	amadan.net/rastrillo/fiesta/example	0.427s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.278s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.656s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.039s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.005s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.336s
ok  	amadan.net/rastrillo/fiesta/internal/site	4.361s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.281s
suite: root took 12s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m7.870000s 0m1.570000s
--- 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	105.368s
suite: test.internal.gallery~1of3 took 107s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m47.540000s 0m1.190000s
--- 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	107.773s
suite: test.internal.gallery~2of3 took 110s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.900000s 0m1.220000s
--- 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	100.683s
suite: test.internal.gallery~3of3 took 103s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.360000s 0m1.300000s
--- 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	80.752s
suite: test.cmd.fiesta~1of2 took 83s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m32.770000s 0m2.140000s
--- 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	93.858s
suite: test.cmd.fiesta~2of2 took 96s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m35.790000s 0m2.380000s
--- 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	106.427s
suite: test.internal.www~1of2 took 109s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.760000s 0m1.570000s
--- 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	103.466s
suite: test.internal.www~2of2 took 106s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.670000s 0m1.590000s
--- 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 4s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m0.900000s 0m0.280000s
--- 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 7s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m3.110000s 0m0.380000s
--- 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' .
10:16PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
10:16PM INF 7 commits scanned.
10:16PM INF scanned ~7814108 bytes (7.81 MB) in 5.36s
10:16PM INF no leaks found
suite: gitleaks took 6s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m7.860000s 0m0.220000s
--- shard 11 of 48 ---
==> shard 11 of 48: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-21-140, 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	5.528s
suite: browser.internal.harness took 9s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m1.780000s 0m1.390000s
--- shard 12 of 48 ---
==> shard 12 of 48: make browser.check~1of13
./hack/gotest.sh browser 'check~1of13'
gotest: on ip-172-31-21-140, 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	127.441s
suite: browser.check~1of13 took 133s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.010000s 0m8.290000s
--- shard 13 of 48 ---
==> shard 13 of 48: make browser.check~2of13
./hack/gotest.sh browser 'check~2of13'
gotest: on ip-172-31-21-140, 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	113.840s
suite: browser.check~2of13 took 119s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m24.130000s 0m7.690000s
--- shard 14 of 48 ---
==> shard 14 of 48: make browser.check~3of13
./hack/gotest.sh browser 'check~3of13'
gotest: on ip-172-31-21-140, 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	126.164s
suite: browser.check~3of13 took 131s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m36.040000s 0m8.870000s
--- shard 15 of 48 ---
==> shard 15 of 48: make browser.check~4of13
./hack/gotest.sh browser 'check~4of13'
gotest: on ip-172-31-21-140, 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	131.986s
suite: browser.check~4of13 took 136s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m40.170000s 0m11.390000s
--- shard 16 of 48 ---
==> shard 16 of 48: make browser.check~5of13
./hack/gotest.sh browser 'check~5of13'
gotest: on ip-172-31-21-140, 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	79.685s
suite: browser.check~5of13 took 86s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.890000s 0m6.860000s
--- shard 17 of 48 ---
==> shard 17 of 48: make browser.check~6of13
./hack/gotest.sh browser 'check~6of13'
gotest: on ip-172-31-21-140, 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	102.562s
suite: browser.check~6of13 took 107s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m22.830000s 0m9.520000s
--- shard 18 of 48 ---
==> shard 18 of 48: make browser.check~7of13
./hack/gotest.sh browser 'check~7of13'
gotest: on ip-172-31-21-140, 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	96.104s
suite: browser.check~7of13 took 101s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.420000s 0m7.440000s
--- shard 19 of 48 ---
==> shard 19 of 48: make browser.check~8of13
./hack/gotest.sh browser 'check~8of13'
gotest: on ip-172-31-21-140, 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	92.827s
suite: browser.check~8of13 took 98s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m17.840000s 0m8.780000s
--- shard 20 of 48 ---
==> shard 20 of 48: make browser.check~9of13
./hack/gotest.sh browser 'check~9of13'
gotest: on ip-172-31-21-140, 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	93.974s
suite: browser.check~9of13 took 98s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.980000s 0m10.640000s
--- shard 21 of 48 ---
==> shard 21 of 48: make browser.check~10of13
./hack/gotest.sh browser 'check~10of13'
gotest: on ip-172-31-21-140, 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	115.097s
suite: browser.check~10of13 took 120s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m30.530000s 0m11.220000s
--- shard 22 of 48 ---
==> shard 22 of 48: make browser.check~11of13
./hack/gotest.sh browser 'check~11of13'
gotest: on ip-172-31-21-140, 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	84.587s
suite: browser.check~11of13 took 89s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.170000s 0m7.640000s
--- shard 23 of 48 ---
==> shard 23 of 48: make browser.check~12of13
./hack/gotest.sh browser 'check~12of13'
gotest: on ip-172-31-21-140, 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	102.703s
suite: browser.check~12of13 took 107s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m21.080000s 0m10.010000s
--- shard 24 of 48 ---
==> shard 24 of 48: make browser.check~13of13
./hack/gotest.sh browser 'check~13of13'
gotest: on ip-172-31-21-140, 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	87.527s
suite: browser.check~13of13 took 92s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.020000s 0m8.960000s
--- 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-21-140, 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	60.685s
suite: browser.internal.gallery~1of18 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.250000s 0m3.150000s
--- 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-21-140, 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	78.767s
suite: browser.internal.gallery~2of18 took 84s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.510000s 0m3.750000s
--- 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-21-140, 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	96.186s
suite: browser.internal.gallery~3of18 took 101s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.440000s 0m4.310000s
--- 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-21-140, 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	101.805s
suite: browser.internal.gallery~4of18 took 108s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.540000s 0m5.480000s
--- 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-21-140, 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	107.802s
suite: browser.internal.gallery~5of18 took 112s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.270000s 0m4.710000s
--- 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-21-140, 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	85.076s
suite: browser.internal.gallery~6of18 took 91s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.440000s 0m3.660000s
--- 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-21-140, 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	77.794s
suite: browser.internal.gallery~7of18 took 84s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.720000s 0m2.650000s
--- 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-21-140, 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	94.319s
suite: browser.internal.gallery~8of18 took 99s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.540000s 0m5.260000s
--- 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-21-140, 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	114.299s
suite: browser.internal.gallery~9of18 took 119s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.950000s 0m7.110000s
--- 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-21-140, 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	86.655s
suite: browser.internal.gallery~10of18 took 92s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.990000s 0m2.620000s
--- 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-21-140, 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	81.508s
suite: browser.internal.gallery~11of18 took 87s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m10.910000s 0m2.610000s
--- 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-21-140, 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	113.286s
suite: browser.internal.gallery~12of18 took 120s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.580000s 0m5.080000s
--- 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-21-140, 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	113.418s
suite: browser.internal.gallery~13of18 took 119s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.500000s 0m5.330000s
--- 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-21-140, 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	121.638s
suite: browser.internal.gallery~14of18 took 128s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.940000s 0m5.910000s
--- 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-21-140, 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	116.567s
suite: browser.internal.gallery~15of18 took 122s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.950000s 0m5.590000s
--- 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-21-140, 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	108.037s
suite: browser.internal.gallery~16of18 took 114s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.150000s 0m4.210000s
--- 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-21-140, 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	114.716s
suite: browser.internal.gallery~17of18 took 121s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m17.400000s 0m5.970000s
--- 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-21-140, 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	114.877s
suite: browser.internal.gallery~18of18 took 120s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.470000s 0m5.520000s
--- 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-21-140, 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	71.077s
suite: browser.internal.gallery.history took 74s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m21.800000s 0m2.150000s
--- 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-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 5 of 20 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	104.496s
suite: browser.internal.www~1of4 took 111s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m38.750000s 0m2.650000s
--- 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-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 5 of 20 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	106.542s
suite: browser.internal.www~2of4 took 113s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m39.100000s 0m2.490000s
--- 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-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 5 of 20 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	107.924s
suite: browser.internal.www~3of4 took 114s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m38.860000s 0m2.390000s
--- 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-21-140, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 5 of 20 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	113.650s
suite: browser.internal.www~4of4 took 120s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.270000s 0m3.260000s
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index de71d83..b5c7d48 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -157,9 +157,12 @@ browser internal/gallery TestTheGallerysOwnControlsAreExactlyTheFloor 2
browser internal/gallery TestThePhoneIndexAndTheWayBack 2
browser internal/gallery TestTheWayBackFromAShellDemoIsATarget 2
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 TestArabicReadsRightToLeft 9
browser internal/www TestCopy 9
browser internal/www TestTheSchemeMenuMarksTheRestoredScheme 9
@@ -168,6 +171,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..811e96f 100644
--- a/internal/www/landing_browser_test.go
+++ b/internal/www/landing_browser_test.go
@@ -1159,6 +1159,239 @@ 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 sums off() over every frame for ms from now: how much the
+ // canvas moved away from rest, and for how long, in one number.
+ async motion(ms, during) {
+ const t0 = performance.now();
+ let sum = 0;
+ const each = () => { sum += m.off(); };
+ if (during) await during(each);
+ while (performance.now() - t0 < ms) { 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
+}
+
+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() summed over frames: the slow scroll %.0f, the shake %.0f (%.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 %.0f and the slow scroll %.0f; 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)
+ }
+}
+
+// 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 1a2b3f2..2dc2bb8 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,6 +40,31 @@
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
@@ -51,7 +77,7 @@
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
@@ -190,7 +216,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
@@ -201,27 +231,55 @@
for (i = 0; i < 120; i++) step(i * DT, true);
}
+ // 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 puff, no hand on the cloth
+ // and no sway on the rope, so it is the same hang every time, 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 = 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++) {
@@ -230,12 +288,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 + 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;
@@ -383,8 +443,10 @@
motion = 0;
while (acc >= DT && n < 3) { step(t); acc -= DT; n++; }
if (probe && n) record();
- // 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);
+ // 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);
@@ -414,6 +476,9 @@
function start() {
if (running || !live || !onScreen || document.hidden || !tex.length || window.siteBuntingStill) return;
running = true; lastT = performance.now(); acc = 0;
+ // 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;
requestAnimationFrame(frame);
}
@@ -437,12 +502,19 @@
}
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;
+ if (Math.abs(v) > 50) { lastInput = now; start(); }
}, { passive: true });
new IntersectionObserver(function (es) { onScreen = es[0].isIntersecting; if (onScreen) start(); }).observe(row);