rastrillo / fiesta Public

Hang the still bunting in the same swags as the moving one

Human

Prompt

Task 4 of Fiesta part 3, after review: the still bunting's rope was almost flat while the canvas hangs in pronounced swags. Derive the SVG's rope curve and each pennant's drop and tilt from the same hang bunting.js computes for its rest pose, and hold the two pictures to each other in the browser, red against the flat SVG, then green.

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's hang before the first frame is now calm: the 120 steps run with no breeze, gust, puff, hand or rope sway, gravity alone, so the pose the canvas opens on is the same every time and nothing but the constants decides it. internal/www hangs the same bunting in Go (buntingGeometry.rest: the same rope nodes, ties and seven-row cloth lattices, the same Verlet step with damping and the velocity cap, the same constraint passes in the same order with the same stiffnesses, slack and extra rope passes, for the same 120 steps) and draws the still SVG from the result: the rope as one path through every node, each pennant's two ties from the rope nodes they hang on to its top corners, and each pennant translated to its top edge's middle and turned to its tilt. A test hook, window.siteBuntingStill, hangs the canvas at rest without starting its loop. TestTheStillBuntingMatchesTheMovingOne screenshots the bunting's strip with the canvas at rest and then with the SVG in its place, and requires the rope's height to agree within two pixels at thirty-one columns and the mean pixel difference to stay under four. TestTheStillBuntingIsTheAnimations counts the rope's tacks on its one path and allows a pennant four units of settling toward its swag's middle.
Agent notes
TestTheStillBuntingMatchesTheMovingOne was red by assertion against the flat SVG with the calm hang already in bunting.js: the canvas's rope sat at row 30 mid-swag against the SVG's row 5, 30 of 31 columns off, mean pixel difference 29; green at 0 columns off and 2.08 with the Go hang, which agrees with the script to the pixel in float64 against the script's Float32Array. The remaining difference is the cloth's weave, which only the canvas paints, and antialiasing. The sag is larger than the reference preview's decorative curve, because it is the physics' own; the pennants hang straight down from their tilted top edges, as the canvas draws them at rest. TestTheBuntingFollowsTheTheme's chooseTheme now waits for the new sheet to join document.styleSheets with rules, since the link's own .sheet can still be the old one. make ci was green in a clean worktree before this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d6392dd327bba41d68b02bf47b09312dd0d77113 parent c3fa599
4 files changed, +382 −55
  • internal/www/landing.go +206 −31
  • internal/www/landing_browser_test.go +124 −2
  • internal/www/landing_test.go +26 −8
  • site/static/bunting.js +26 −14

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.100s
=== 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.020s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	0.959s
ok  	amadan.net/rastrillo/fiesta/check	4.923s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	6.726s
ok  	amadan.net/rastrillo/fiesta/example	0.219s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.197s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.536s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.029s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	5.004s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.014s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.438s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.239s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.009s
ok  	amadan.net/rastrillo/fiesta/internal/www	3.157s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: 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.
--- shard 3 of 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
7:21PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
7:21PM INF 1 commits scanned.
7:21PM INF scanned ~5797573 bytes (5.80 MB) in 3.06s
7:21PM INF no leaks found
--- shard 4 of 12 ---
==> shard 4 of 12: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	10.888s
--- shard 5 of 12 ---
==> shard 5 of 12: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	193.751s
--- shard 6 of 12 ---
==> shard 6 of 12: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	205.995s
--- shard 7 of 12 ---
==> shard 7 of 12: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	201.206s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	72.800s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 24 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	33.755s
--- shard 10 of 12 ---
==> shard 10 of 12: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 14 of 28 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	26.537s
--- shard 11 of 12 ---
==> shard 11 of 12: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 14 of 28 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	16.721s
diff --git a/internal/www/landing.go b/internal/www/landing.go
index fc2003f..a6eab41 100644
--- a/internal/www/landing.go
+++ b/internal/www/landing.go
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"io/fs"
+ "math"
"strings"
"amadan.net/rastrillo/fiesta"
@@ -25,61 +26,235 @@ type page struct {
// buntingGeometry is the bunting's shape in the SVG's own units, one
// unit a pixel at scale 1: pennants FW wide at the top and FH to the
-// tip, hung Tie below a rope that runs TopY from the top, Pitch apart,
-// Per to a swag with End clear at each tack, Swags swags across a row
-// H tall. Sag is how far the rope's middle dips between two tacks.
+// tip, a lattice NY rows deep, hung Tie below a rope that starts TopY
+// from the top, Pitch apart, Per to a swag with End clear at each
+// tack, Swags swags across a row H tall. The rest are the cloth's
+// physics: gravity G, velocity damping Damp, the cloth's Mass, Iter
+// constraint passes a step of DT, a velocity cap VMax, the rope's
+// inverse mass RopeInv, the bending stiffness Bend and the sewn tape's
+// Hem, the rope's Slack per link, RopePasses extra passes over the
+// rope, and the Hang in steps before the first frame.
//
// These are bunting.js's own numbers, so the still picture a reader
-// gets with no JS or under reduced motion is the arrangement the
-// canvas animates; TestTheStillBuntingIsTheAnimations holds the two
-// together.
+// gets with no JS or under reduced motion is the hang the canvas opens
+// on: TestTheStillBuntingIsTheAnimations holds the geometry to the
+// script's constants, and TestTheStillBuntingMatchesTheMovingOne holds
+// the two pictures to each other.
type buntingGeometry struct {
- FW, FH, TopY, Tie, Pitch, Per, End, Swags, H int
- Sag float64
+ FW, FH, NY, TopY, Tie, Pitch, Per, End, Swags, H int
+ G, Damp, Mass, DT, VMax, RopeInv, Bend, Hem, Slack float64
+ Iter, RopePasses, Hang int
}
-var buntingShape = buntingGeometry{FW: 36, FH: 44, TopY: 4, Tie: 7, Pitch: 48, Per: 6, End: 34, Swags: 8, H: 96, Sag: 10}
+var buntingShape = buntingGeometry{
+ FW: 36, FH: 44, NY: 7, TopY: 4, Tie: 7, Pitch: 48, Per: 6, End: 34, Swags: 8, H: 96,
+ G: 650, Damp: 0.955, Mass: 1.15, DT: 1.0 / 60, VMax: 6, RopeInv: 0.25, Bend: 0.1, Hem: 0.7, Slack: 1.005,
+ Iter: 5, RopePasses: 6, Hang: 120,
+}
// pennantFills are the six cloths, in the order they hang. They live
// on the SVG as attributes, and bunting.js reads them from there, so
// site.css carries Fiesta's tokens only.
var pennantFills = []string{"#2257df", "#f2b632", "#e8553f", "#16a37f", "#7aa2ff", "#e84f8a"}
+// buntingRest is the bunting hung at rest: the rope's nodes in order,
+// and for each pennant the rope nodes its two ties hang from and its
+// two top corners.
+type buntingRest struct {
+ ropeX, ropeY []float64
+ flags []pennantRest
+}
+
+type pennantRest struct {
+ tieL, tieR [2]float64 // the rope nodes the ties hang from
+ cornerL, cornerR [2]float64 // the cloth's top corners
+}
+
+// rest hangs the bunting as bunting.js does before its first frame,
+// with the same rope, ties and cloth lattices, the same Verlet step
+// and the same constraint passes in the same order, Hang steps under
+// gravity and nothing else. The script's hang is calm (no wind), so
+// the two land on the same pose; it keeps x and y only, since with no
+// wind nothing ever leaves the plane.
+func (g buntingGeometry) rest() buntingRest {
+ span := g.Pitch*g.Per + 2*g.End
+ row := span * g.Swags
+ fw, tie, topY := float64(g.FW), float64(g.Tie), float64(g.TopY)
+ dx, dy := fw/float64(g.NY-1), float64(g.FH)/float64(g.NY-1)
+ dd := math.Hypot(dx/2, dy)
+
+ // The rope, span by span: a tack, then for each pennant a node over
+ // each top corner and one between them, a node in each gap, and
+ // the closing tack.
+ var rx []float64
+ var ties [][2]int
+ for s := 0; s < g.Swags; s++ {
+ x0 := float64(s * span)
+ rx = append(rx, x0, x0+float64(g.End)/2)
+ for p := 0; p < g.Per; p++ {
+ cx := x0 + float64(g.End+g.Pitch*p) + float64(g.Pitch)/2
+ ties = append(ties, [2]int{len(rx), len(rx) + 2})
+ rx = append(rx, cx-fw/2, cx, cx+fw/2)
+ if p < g.Per-1 {
+ rx = append(rx, cx+float64(g.Pitch)/2)
+ } else {
+ rx = append(rx, x0+float64(span)-float64(g.End)/2)
+ }
+ }
+ }
+ rx = append(rx, float64(row))
+ ropeN := len(rx)
+ n := ropeN + len(ties)*g.NY*(g.NY+1)/2
+ X, Y, PX, PY, INV := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n)
+ // cons is a, b, length, stiffness, four per constraint, in the
+ // order the script solves them.
+ var cons []float64
+ for i, x := range rx {
+ X[i], PX[i], Y[i], PY[i] = x, x, topY, topY
+ if math.Mod(x, float64(span)) != 0 {
+ INV[i] = g.RopeInv
+ }
+ if i > 0 {
+ cons = append(cons, float64(i-1), float64(i), (x-rx[i-1])*g.Slack, 1)
+ }
+ }
+ idx := ropeN
+ var corners [][2]int
+ for k, t := range ties {
+ cx := float64(g.End+(k%g.Per)*g.Pitch) + float64(g.Pitch)/2 + float64((k/g.Per)*span)
+ rows := make([][]int, g.NY)
+ for j := 0; j < g.NY; j++ {
+ nj, w := g.NY-j, fw-dx*float64(j)
+ for i := 0; i < nj; i++ {
+ X[idx], PX[idx] = cx-w/2+dx*float64(i), cx-w/2+dx*float64(i)
+ Y[idx], PY[idx] = topY+tie+dy*float64(j), topY+tie+dy*float64(j)
+ INV[idx] = 1
+ rows[j] = append(rows[j], idx)
+ idx++
+ }
+ }
+ cons = append(cons, float64(t[0]), float64(rows[0][0]), tie, 1, float64(t[1]), float64(rows[0][g.NY-1]), tie, 1, float64(rows[0][0]), float64(rows[0][g.NY-1]), fw, g.Hem)
+ for j := 0; j < g.NY; j++ {
+ rj := rows[j]
+ nj := len(rj)
+ for i := 0; i < nj; i++ {
+ a := float64(rj[i])
+ if i < nj-1 {
+ cons = append(cons, a, float64(rj[i+1]), dx, 1)
+ }
+ if j < g.NY-1 {
+ rb := rows[j+1]
+ if i > 0 {
+ cons = append(cons, a, float64(rb[i-1]), dd, 1)
+ }
+ if i < len(rb) {
+ cons = append(cons, a, float64(rb[i]), dd, 1)
+ }
+ }
+ if j < g.NY-2 && i > 0 && i-1 < len(rows[j+2]) {
+ cons = append(cons, a, float64(rows[j+2][i-1]), 2*dy, g.Bend)
+ }
+ if i < nj-2 {
+ st := g.Bend
+ if j == 0 {
+ st = g.Hem
+ }
+ cons = append(cons, a, float64(rj[i+2]), 2*dx, st)
+ }
+ }
+ }
+ corners = append(corners, [2]int{rows[0][0], rows[0][g.NY-1]})
+ }
+
+ solve := func(a, b int, length, stiff float64) {
+ wa, wb := INV[a], INV[b]
+ ws := wa + wb
+ if ws == 0 {
+ return
+ }
+ ex, ey := X[b]-X[a], Y[b]-Y[a]
+ d := math.Sqrt(ex*ex + ey*ey)
+ if d == 0 {
+ d = 1e-6
+ }
+ s := (d - length) / d * stiff / ws
+ X[a] += ex * s * wa
+ Y[a] += ey * s * wa
+ X[b] -= ex * s * wb
+ Y[b] -= ey * s * wb
+ }
+ dt2 := g.DT * g.DT
+ for step := 0; step < g.Hang; step++ {
+ for id := 0; id < n; id++ {
+ if INV[id] == 0 {
+ continue
+ }
+ vx, vy := (X[id]-PX[id])*g.Damp, (Y[id]-PY[id])*g.Damp
+ if v2 := vx*vx + vy*vy; v2 > g.VMax*g.VMax {
+ k := g.VMax / math.Sqrt(v2)
+ vx, vy = vx*k, vy*k
+ }
+ PX[id], PY[id] = X[id], Y[id]
+ X[id] += vx
+ Y[id] += vy + g.G*dt2
+ }
+ for it := 0; it < g.Iter; it++ {
+ for k := 0; k < len(cons); k += 4 {
+ solve(int(cons[k]), int(cons[k+1]), cons[k+2], cons[k+3])
+ }
+ for r := 0; r < g.RopePasses; r++ {
+ for k := 0; k < (ropeN-1)*4; k += 4 {
+ solve(int(cons[k]), int(cons[k+1]), cons[k+2], 1)
+ }
+ }
+ }
+ }
+
+ out := buntingRest{ropeX: X[:ropeN], ropeY: Y[:ropeN]}
+ for k, c := range corners {
+ t := ties[k]
+ out.flags = append(out.flags, pennantRest{
+ tieL: [2]float64{X[t[0]], Y[t[0]]}, tieR: [2]float64{X[t[1]], Y[t[1]]},
+ cornerL: [2]float64{X[c[0]], Y[c[0]]}, cornerR: [2]float64{X[c[1]], Y[c[1]]},
+ })
+ }
+ return out
+}
+
// stillBunting draws the bunting at rest as an SVG: one pennant
// definition per cloth, origin at the middle of its top edge, with a
-// sewn tape and a stitch line along the top; then for each swag the
-// rope between two tacks, and for each pennant its two ties and the
-// pennant itself. The classes are what site.css colours (the rope and
-// ties are a Fiesta token) and what bunting.js reads.
+// sewn tape and a stitch line along the top; the rope through every
+// node of the hang; and each pennant's two ties and the pennant
+// itself, translated to its top edge's middle and turned to its tilt.
+// The classes are what site.css colours (the rope and ties are a
+// Fiesta token) and what bunting.js reads.
func stillBunting(g buntingGeometry) template.HTML {
span := g.Pitch*g.Per + 2*g.End
- row := span * g.Swags
half := float64(g.FW) / 2
+ hang := g.rest()
var b strings.Builder
- fmt.Fprintf(&b, `<svg class="site-flags" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMin slice" focusable="false">`+"\n<defs>\n", row, g.H)
+ fmt.Fprintf(&b, `<svg class="site-flags" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMin slice" focusable="false">`+"\n<defs>\n", span*g.Swags, g.H)
for k, fill := range pennantFills {
fmt.Fprintf(&b, `<g id="site-p%d"><polygon fill="%s" points="%g,0 %g,0 0,%d"/><polygon fill="#000" opacity=".16" points="%g,0 %g,0 %.1f,3.2 %.1f,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M%.1f 3.6H%.1f"/></g>`+"\n",
k, fill, -half, half, g.FH, -half, half, half-1.3, -(half - 1.3), -(half - 1.5), half-1.5)
}
b.WriteString("</defs>\n")
- // The rope between two tacks is a quadratic curve through its
- // midpoint, Sag below the tacks; this is its height at x.
- ropeY := func(x float64, s int) float64 {
- t := (x - float64(s*span)) / float64(span)
- return float64(g.TopY) + 2*t*(1-t)*g.Sag
- }
- for s := 0; s < g.Swags; s++ {
- x0 := s * span
- fmt.Fprintf(&b, `<path class="site-span" d="M%d %d Q%d %g %d %d"/>`+"\n", x0, g.TopY, x0+span/2, float64(g.TopY)+g.Sag, x0+span, g.TopY)
- for p := 0; p < g.Per; p++ {
- k := s*g.Per + p
- cx := x0 + g.End + g.Pitch*p + g.Pitch/2
- top := ropeY(float64(cx), s) + float64(g.Tie)
- l, r := float64(cx)-half, float64(cx)+half
- fmt.Fprintf(&b, `<path class="site-tie" d="M%g %.2fV%.2fM%g %.2fV%.2f"/>`+"\n", l, ropeY(l, s), top, r, ropeY(r, s), top)
- fmt.Fprintf(&b, `<g class="site-flag" data-c="%d" transform="translate(%d %.2f)"><use href="#site-p%d"/></g>`+"\n", k%len(pennantFills), cx, top, k%len(pennantFills))
+ b.WriteString(`<path class="site-span" d="`)
+ for i := range hang.ropeX {
+ if i == 0 {
+ fmt.Fprintf(&b, "M%.2f %.2f", hang.ropeX[i], hang.ropeY[i])
+ } else {
+ fmt.Fprintf(&b, "L%.2f %.2f", hang.ropeX[i], hang.ropeY[i])
}
}
+ b.WriteString("\"/>\n")
+ for k, f := range hang.flags {
+ fmt.Fprintf(&b, `<path class="site-tie" d="M%.2f %.2fL%.2f %.2fM%.2f %.2fL%.2f %.2f"/>`+"\n",
+ f.tieL[0], f.tieL[1], f.cornerL[0], f.cornerL[1], f.tieR[0], f.tieR[1], f.cornerR[0], f.cornerR[1])
+ mx, my := (f.cornerL[0]+f.cornerR[0])/2, (f.cornerL[1]+f.cornerR[1])/2
+ tilt := math.Atan2(f.cornerR[1]-f.cornerL[1], f.cornerR[0]-f.cornerL[0]) * 180 / math.Pi
+ fmt.Fprintf(&b, `<g class="site-flag" data-c="%d" transform="translate(%.2f %.2f) rotate(%.2f)"><use href="#site-p%d"/></g>`+"\n", k%len(pennantFills), mx, my, tilt, k%len(pennantFills))
+ }
b.WriteString("</svg>")
return template.HTML(b.String())
}
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go
index d545a24..c62b7b4 100644
--- a/internal/www/landing_browser_test.go
+++ b/internal/www/landing_browser_test.go
@@ -8,9 +8,12 @@
package www_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "image"
+ imagepng "image/png"
"net/http"
"strings"
"testing"
@@ -126,7 +129,9 @@ func visible(t *testing.T, ctx context.Context, sel string) shown {
var themeURL = map[string]string{"day": "/1/fiesta.css", "plain": "/1/fiesta-plain.css", "signal": "/1/fiesta-signal.css"}
// chooseTheme presses one theme tab and waits for its stylesheet to be
-// the one in effect.
+// the one in effect: the link's href swapped, and a sheet at that URL
+// with rules in document.styleSheets, which it joins only once loaded
+// (the link's own .sheet can still be the old one meanwhile).
func chooseTheme(t *testing.T, ctx context.Context, theme string) {
t.Helper()
mustRun(t, ctx, soon(`.site-themebar`),
@@ -134,7 +139,7 @@ func chooseTheme(t *testing.T, ctx context.Context, theme string) {
deadline := time.Now().Add(10 * time.Second)
for {
var ok bool
- mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.getElementById("site-theme"); return !!l && l.getAttribute("href") === %q && !!l.sheet; })()`, themeURL[theme]), &ok))
+ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.getElementById("site-theme"), url = %q; return !!l && l.getAttribute("href") === url && [...document.styleSheets].some(s => s.href && new URL(s.href).pathname === url && s.cssRules.length > 0); })()`, themeURL[theme]), &ok))
if ok {
return
}
@@ -968,6 +973,123 @@ func TestTheBuntingFollowsTheTheme(t *testing.T) {
rig.Screen("#headline", "the page in Signal")
}
+// capture screenshots one rectangle of the page, in CSS pixels.
+func capture(t *testing.T, ctx context.Context, x, y, w, h float64) image.Image {
+ t.Helper()
+ var png []byte
+ mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
+ var err error
+ png, err = cdppage.CaptureScreenshot().WithClip(&cdppage.Viewport{X: x, Y: y, Width: w, Height: h, Scale: 1}).Do(ctx)
+ return err
+ }))
+ img, err := imagepng.Decode(bytes.NewReader(png))
+ if err != nil {
+ t.Fatalf("decoding the screenshot: %v", err)
+ }
+ return img
+}
+
+// rgb reads a pixel as 8-bit channels.
+func rgb(img image.Image, x, y int) [3]int {
+ r, g, b, _ := img.At(x, y).RGBA()
+ return [3]int{int(r >> 8), int(g >> 8), int(b >> 8)}
+}
+
+// apart is the largest channel difference between two colours.
+func apart(a, b [3]int) int {
+ d := 0
+ for i := range a {
+ if x := a[i] - b[i]; x > d {
+ d = x
+ } else if -x > d {
+ d = -x
+ }
+ }
+ return d
+}
+
+// topInk is the first row from the top of column x whose pixel is not
+// the background, or -1 when the column is clear: where the rope
+// crosses the column, since nothing hangs above the rope.
+func topInk(img image.Image, x int, bg [3]int) int {
+ for y := 0; y < img.Bounds().Dy(); y++ {
+ if apart(rgb(img, x, y), bg) > 40 {
+ return y
+ }
+ }
+ return -1
+}
+
+// meanApart is the mean over every pixel of the largest channel
+// difference between two pictures of the same size.
+func meanApart(a, b image.Image) float64 {
+ w, h := a.Bounds().Dx(), a.Bounds().Dy()
+ sum := 0
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ sum += apart(rgb(a, x, y), rgb(b, x, y))
+ }
+ }
+ return float64(sum) / float64(w*h)
+}
+
+// TestTheStillBuntingMatchesTheMovingOne: the canvas hung at rest,
+// before any wind, and the still SVG in the markup are the same
+// picture. With the script's freeze hook set the canvas never moves;
+// the bunting's strip is screenshotted with the canvas showing, then
+// with the SVG showing in its place, and the two are compared: the
+// rope's height at thirty columns across the page within two pixels,
+// and the mean pixel difference over the strip small (the canvas's
+// cloth carries a weave the SVG's does not, and edges antialias apart,
+// so it is never zero). A flat rope under a sagging one, or pennants
+// hung at a different height, fails both.
+func TestTheStillBuntingMatchesTheMovingOne(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ onNewDocument(t, ctx, `window.siteBuntingStill = true;`)
+ open(t, ctx, rig, 1280, 900)
+ waitBunting(t, ctx, "the canvas hung at rest", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
+ var strip struct{ Top, Height, Width float64 }
+ at(t, ctx, `(() => { const r = document.getElementById("bunting").getBoundingClientRect(), c = document.querySelector("#bunting canvas").getBoundingClientRect(); return JSON.stringify({Top: r.top, Height: c.height, Width: r.width}); })()`, &strip)
+ if strip.Height < 100 || strip.Width < 1000 {
+ t.Fatalf("the bunting's strip is %.0f by %.0f; this drive expects the canvas's full height across the page", strip.Width, strip.Height)
+ }
+ moving := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
+ mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("bunting").classList.remove("site-bunting-live"); document.querySelector("#bunting canvas").style.visibility = "hidden"; true`, nil))
+ still := waitBunting(t, ctx, "the SVG shown in the canvas's place", func(b bunting) bool { return b.SvgShown && !b.CanvasShown })
+ _ = still
+ stillImg := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
+
+ w, h := moving.Bounds().Dx(), moving.Bounds().Dy()
+ if w != stillImg.Bounds().Dx() || h != stillImg.Bounds().Dy() {
+ t.Fatalf("the two screenshots are %dx%d and %dx%d", w, h, stillImg.Bounds().Dx(), stillImg.Bounds().Dy())
+ }
+ bg := rgb(moving, w-1, h-1)
+ off, columns := 0, 0
+ for x := 20; x < w-20; x += 40 {
+ a, b := topInk(moving, x, bg), topInk(stillImg, x, bg)
+ if a < 0 || b < 0 {
+ t.Errorf("column %d: the rope was not found (canvas row %d, SVG row %d)", x, a, b)
+ continue
+ }
+ columns++
+ if d := a - b; d > 2 || d < -2 {
+ off++
+ t.Errorf("column %d: the canvas's rope is at row %d and the SVG's at row %d", x, a, b)
+ }
+ }
+ mean := meanApart(moving, stillImg)
+ t.Logf("%d columns sampled, %d off by more than 2px; mean pixel difference %.2f", columns, off, mean)
+ if columns < 20 {
+ t.Fatalf("only %d columns had a rope; the strip was not the bunting", columns)
+ }
+ if mean > 4 {
+ t.Errorf("the canvas at rest and the still SVG differ by %.2f per pixel on average; want under 4", mean)
+ }
+ rig.Screen("#headline", "the page with the still bunting")
+}
+
// 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/internal/www/landing_test.go b/internal/www/landing_test.go
index a73a9ed..30d7c22 100644
--- a/internal/www/landing_test.go
+++ b/internal/www/landing_test.go
@@ -259,12 +259,23 @@ func TestTheStillBuntingIsTheAnimations(t *testing.T) {
if want := fmt.Sprintf("0 0 %d %d", span*swags, h); attr(svg, "viewBox") != want {
t.Errorf("the still bunting's viewBox is %q; the animation's row is %q", attr(svg, "viewBox"), want)
}
- spans, flags, defs := 0, 0, 0
- translate := regexp.MustCompile(`^translate\((-?[\d.]+) (-?[\d.]+)\)$`)
+ ropes, tacks, flags, defs := 0, 0, 0, 0
+ translate := regexp.MustCompile(`^translate\((-?[\d.]+) (-?[\d.]+)\)( rotate\(-?[\d.]+\))?$`)
+ point := regexp.MustCompile(`[ML](-?[\d.]+) (-?[\d.]+)`)
walk(svg, func(n *xhtml.Node) {
switch {
case n.Data == "path" && attr(n, "class") == "site-span":
- spans++
+ // The rope is one path through every node of the hang, as
+ // the canvas draws it; its tacks are the nodes that never
+ // move, one at each span's end, at the rope's own height.
+ ropes++
+ for _, m := range point.FindAllStringSubmatch(attr(n, "d"), -1) {
+ x, _ := strconv.ParseFloat(m[1], 64)
+ y, _ := strconv.ParseFloat(m[2], 64)
+ if int(x)%span == 0 && x == float64(int(x)) && y == float64(jsConstant(t, string(js), "TOPY")) {
+ tacks++
+ }
+ }
case n.Data == "polygon" && strings.Contains(attr(n, "points"), fmt.Sprintf("0,%d", fh)) && strings.HasPrefix(attr(n, "points"), fmt.Sprintf("-%d,0 %d,0", fw/2, fw/2)):
defs++
case n.Data == "g" && attr(n, "class") == "site-flag":
@@ -272,21 +283,28 @@ func TestTheStillBuntingIsTheAnimations(t *testing.T) {
flags++
tm := translate.FindStringSubmatch(attr(n, "transform"))
if tm == nil {
- t.Errorf("pennant %d has transform %q; want a plain translate", k, attr(n, "transform"))
+ t.Errorf("pennant %d has transform %q; want a translate, with at most a rotate", k, attr(n, "transform"))
return
}
x, _ := strconv.ParseFloat(tm[1], 64)
want := float64((k/per)*span + end + (k%per)*pitch + pitch/2)
- if x != want {
- t.Errorf("pennant %d hangs at x=%g; the animation hangs it at %g", k, x, want)
+ // Within four units of where it is tied: as the rope sags
+ // it draws a swag's pennants a little toward its middle,
+ // and the picture test holds the exact hang. A pennant out
+ // by a pitch, or a swag out by its span, is still caught.
+ if x < want-4 || x > want+4 {
+ t.Errorf("pennant %d hangs at x=%g; the animation ties it at %g", k, x, want)
}
if c, _ := strconv.Atoi(attr(n, "data-c")); c != k%6 {
t.Errorf("pennant %d is cloth %d; want %d, cycling through the six", k, c, k%6)
}
}
})
- if spans != swags {
- t.Errorf("the still bunting has %d spans; the animation has %d swags", spans, swags)
+ if ropes != 1 {
+ t.Errorf("the still bunting has %d rope paths; the canvas draws one rope", ropes)
+ }
+ if tacks != swags+1 {
+ t.Errorf("the still bunting's rope has %d tacks; the animation's %d swags hang between %d", tacks, swags, swags+1)
}
if flags != swags*per {
t.Errorf("the still bunting has %d pennants; the animation hangs %d, %d a swag", flags, swags*per, per)
diff --git a/site/static/bunting.js b/site/static/bunting.js
index f7f2681..90dd26f 100644
--- a/site/static/bunting.js
+++ b/site/static/bunting.js
@@ -185,28 +185,37 @@
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 };
});
cons = new Float32Array(c);
- // Hang the row before the first frame, so the page never opens on a drop.
- for (i = 0; i < 120; i++) step(i * DT);
+ // Hang the row before the first frame, so the page never opens on
+ // a drop: 120 calm steps, gravity alone, the same hang the still
+ // SVG in the markup is drawn from (internal/www computes it with
+ // 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);
}
- function step(t) {
+ // 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.
+ function step(t, calm) {
var dt2 = DT * DT, now = performance.now(), i, f, q;
var tn = now / 1000;
- 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();
+ 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;
}
- while (puffs.length && tn - puffs[0].t > 1.5) puffs.shift();
- gust *= 0.97;
- var p = touch && now - touch.t < 90 ? touch : null;
- breeze += ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02;
+ 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 = (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 + gust;
var wz = 200 * b, wx = 50 * b * Math.sin(t * 0.23 + cx * 0.001);
- puffAt(cx, tn);
+ if (calm) PAX = PAY = PAZ = 0; else puffAt(cx, tn);
for (q = 0; q < F.ids.length; q++) {
var id = F.ids[q], depth = DEP[id], dep = 0.35 + 0.65 * depth, d2 = depth * depth;
// The breeze ripples down the cloth; the tip, free on three
@@ -218,7 +227,7 @@
if (p) contact(id, p);
}
}
- for (i = 0; i < ropeN; i++) if (INV[i]) integrate(i, 0, 0, 30 * Math.sin(t * 0.5 + i * 0.05) / MASS, dt2);
+ 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);
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;
@@ -371,8 +380,11 @@
if (still) { running = false; return; }
requestAnimationFrame(frame);
}
+ // 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) return;
+ if (running || !live || !onScreen || document.hidden || !tex.length || window.siteBuntingStill) return;
running = true; lastT = performance.now(); acc = 0;
requestAnimationFrame(frame);
}