rastrillo / fiesta Public

Keep a shake's flutter out of the bunting's calm pre-roll

The jig flutter stayed on in calm steps, so a rebuild right after a shake (a resize, or motion allowed again) hung the cloth up to 17 units from the rest pose, and its first frame was no longer the still SVG. Calm steps now leave it out. The probe gains hung(), the pose the last rebuild hung, so a drive can hold a rebuild after a shake to the one hung at load.
Human

Prompt

Astra reviewed the branch: Ship after fixes (bunting-review.md). Fix all four without changing the feel. 2. bunting.js:292: zero the jig flutter in calm steps so a rebuild after a shake hangs the same rest pose; add a check that the calm pose after a shake equals the calm pose before.

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: no jig in calm steps; probe hung() and Builds. TestTheBuntingHangsTheSameAfterAShake; its weight.
Agent notes
Red before the fix: 17.05 units apart (want under 0.01). Green: 0.0000.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a972d8655f670dde1acb6ab6c5cac40cd4678906 parent 2920948
3 files changed, +52 −7
  • hack/test-weights.txt +1 −0
  • internal/www/landing_browser_test.go +40 −0
  • site/static/bunting.js +11 −7
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index b012699..cd4b26f 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -164,6 +164,7 @@ 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
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go
index 0e271e7..ef9b0f2 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"
@@ -1217,6 +1218,7 @@ 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 {
@@ -1391,6 +1393,44 @@ func TestAScrollAfterIdleWakesTheBunting(t *testing.T) {
}
}
+// 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.
diff --git a/site/static/bunting.js b/site/static/bunting.js
index 00cd1b1..8a3efe3 100644
--- a/site/static/bunting.js
+++ b/site/static/bunting.js
@@ -71,8 +71,9 @@
// 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.
- var windless = !!window.siteBuntingWindless, probe = window.siteBuntingProbe ? { minY: Infinity, maxY: -Infinity, minX: Infinity, maxX: -Infinity, maxZ: 0, stretch: 0, finite: true } : null;
+ // 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";
@@ -229,6 +230,7 @@
// 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
@@ -240,9 +242,10 @@
}
// step advances the cloth one DT. calm is the hang before the first
- // 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.
+ // 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, airY = 0, airB = 0, jy = 0, snap = 0;
@@ -289,7 +292,7 @@
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 + 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);
+ (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);
}
}
@@ -467,8 +470,9 @@
}
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 });
+ 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