eleven / messenger Public

prototype: rumble through the build, confetti when the space is ready (#358)

The choreography no longer goes quiet for the wait. After the snap the
card keeps a steady working rumble (level 0.85, no crank) through the
whole building state, dust still flying — the machine is audibly doing
something — and the moment the space is ready the rattle cuts out and
confetti bursts from the card's top edge (140 pieces, desk-LED colours +
star gold, floatier gravity than the dust so it reads as paper). The
dust takes a quick 600ms bow at the handover; the loop parks itself once
the last piece lands. Phase machine (slide → snap → build → done)
replaces the snapped flag + setTimeout, and reset cancels the running
frame so it can never double the loop.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 35ce983c5c2cbbc4f76835e0de9a183fd7a22ecc parent e263054
1 file changed, +94 −53
  • prototypes/space-lever.html +94 −53
diff --git a/prototypes/space-lever.html b/prototypes/space-lever.html
index 7b1d40c..506eaa6 100644
--- a/prototypes/space-lever.html
+++ b/prototypes/space-lever.html
@@ -270,15 +270,60 @@ function stepDust(level) {
g.globalAlpha = 1;
}
+// ---- confetti (the "space is ready" payoff) -------------------------------------
+// Bursts from the card's top edge the moment building finishes — the rattle cuts
+// out and the room throws paper. Same canvas as the dust.
+let confetti = [];
+function spawnConfetti() {
+ if (reduced || !dust) return;
+ const r = card.getBoundingClientRect();
+ const css = getComputedStyle(document.documentElement);
+ const colors = ["--accent", "--led-g", "--led-a", "--led-r"].map((v) => css.getPropertyValue(v).trim());
+ colors.push("#f2b705"); // the star gold
+ confetti = Array.from({ length: 140 }, () => ({
+ x: r.left + Math.random() * r.width,
+ y: r.top + 6,
+ vx: (Math.random() - 0.5) * 9,
+ vy: -(5 + Math.random() * 9),
+ w: 4 + Math.random() * 5,
+ h: 3 + Math.random() * 4,
+ rot: Math.random() * Math.PI,
+ vr: (Math.random() - 0.5) * 0.3,
+ c: colors[(Math.random() * colors.length) | 0],
+ life: 1400 + Math.random() * 900, // ms
+ t: 0,
+ }));
+}
+function stepConfetti(dt) {
+ if (!dust || !confetti.length) return;
+ const { g, h } = dust;
+ confetti = confetti.filter((p) => (p.t += dt) < p.life && p.y < h + 20);
+ for (const p of confetti) {
+ p.vy += 0.22; // floatier than the dust — it's paper, not grit
+ p.vx *= 0.99;
+ p.x += p.vx; p.y += p.vy; p.rot += p.vr;
+ g.save();
+ g.translate(p.x, p.y);
+ g.rotate(p.rot);
+ g.globalAlpha = Math.min(1, 4 * (1 - p.t / p.life)); // hold, then fade out
+ g.fillStyle = p.c;
+ g.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
+ g.restore();
+ }
+ g.globalAlpha = 1;
+}
+
// ---- fader state ---------------------------------------------------------------
// value: 1..10 while sliding (continuous), 11 only via the snap.
+// phase: slide → snap (the wild 950ms) → build (~5s, still rumbling) → done.
let value = 1;
let pressure = 0; // 0..1 — builds while pulling past 10, decays otherwise
let dragging = false;
let overpull = false; // pointer currently held beyond the 10 stop
-let snapped = false; // reached ELEVEN (locks the fader, plays the finish)
-let snapT0 = 0;
+let phase = "slide";
+let phaseT0 = 0;
let lastT = performance.now();
+let rafId = 0;
const fader = $("fader"), cap = $("cap"), card = $("card"), meter = $("meter");
@@ -312,31 +357,34 @@ function xForValue(v) { // slot-fraction for a 1..10 value
return ((v - 1) / 9) * RUN_END;
}
function paint() {
+ const past = phase !== "slide"; // snap/build/done: the cap sits AT eleven
let fx;
- if (snapped) fx = 1;
+ if (past) fx = 1;
else fx = xForValue(value) + (pressure > 0 ? (pressure * (1 - RUN_END) * 0.35) : 0); // strain toward 11
cap.style.left = (fx * 100) + "%";
- cap.setAttribute("aria-valuenow", snapped ? 11 : Math.round(value));
+ cap.setAttribute("aria-valuenow", past ? 11 : Math.round(value));
for (let n = 1; n <= 10; n++) nums[n - 1].classList.toggle("lit", value >= n - 0.01);
- nums[10].classList.toggle("arming", pressure > 0.05 && !snapped);
- nums[10].classList.toggle("hit", snapped);
+ nums[10].classList.toggle("arming", pressure > 0.05 && !past);
+ nums[10].classList.toggle("hit", past);
// VU: lit up to the current notch, with a one-LED flicker while the signal's
// hot so it reads as a live meter, not a progress bar. At eleven: all of it,
// and the last LED strobes.
const hot = levelNow();
- const lit = snapped ? 11 : Math.round(value) + (hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0);
+ const lit = past ? 11 : Math.round(value) + (hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0);
leds.forEach((led, i) => {
const on = i < lit;
led.className = "led" + (on ? " on-" + led.dataset.c : "") +
- (snapped && i === 10 ? " fury" : "");
+ (phase === "snap" && i === 10 ? " fury" : "");
});
}
// level drives shake + dust, mirroring dialPaint: silent below ~5, trembling by
-// 10, and PAST 1.0 only at eleven (which sends the dust to the top).
+// 10, PAST 1.0 only at the snap — and a steady working rumble while building.
function levelNow() {
- if (snapped) return 1.2;
+ if (phase === "snap") return 1.2;
+ if (phase === "build") return 0.85; // still loud in there — it's working
+ if (phase === "done") return 0;
const base = Math.max(0, (value - 5) / 6); // 0 at 5 … ~0.83 at 10
return base + pressure * 0.25; // the hold adds strain
}
@@ -345,18 +393,20 @@ function tick(t) {
const dt = Math.min(50, t - lastT); lastT = t;
// Pressure: build while pulled past the stop, decay when released/backed off.
- if (!snapped) {
+ if (phase === "slide") {
if (overpull && value >= 9.99) pressure = Math.min(1, pressure + dt / P.hold);
else pressure = Math.max(0, pressure - dt / 300);
- if (pressure >= 1) snapToEleven();
+ if (pressure >= 1) { phase = "snap"; phaseT0 = t; value = 10; pressure = 0; } // the SNAP: no travel, no ease
}
const level = levelNow();
stepDust(level);
+ stepConfetti(dt);
- // Loudness shake on the whole card (shell.js dialPaint, CRANKED at eleven).
+ // Loudness shake on the whole card: CRANKED at the snap, easing to a steady
+ // rumble while the space builds, cutting out the moment it's ready.
if (!reduced && level > 0) {
- const mult = snapped ? P.crank : 1;
+ const mult = phase === "snap" ? P.crank : 1;
const a = P.shake * level * mult;
card.style.transform =
`translate(${((Math.random() - .5) * 2 * a).toFixed(2)}px, ${((Math.random() - .5) * 2 * a).toFixed(2)}px)` +
@@ -365,41 +415,31 @@ function tick(t) {
card.style.transform = "";
}
- // The finish: wild for 620ms, bow for 330ms (dust fades mid-flight), then the
- // building state (the ~5s the real create takes) and the confirmation pane.
- // Snap timing lifted from clickDialToEleven.
- if (snapped) {
- const dtSnap = t - snapT0;
- if (dust) dust.fade = Math.min(1, Math.max(0, 1 - (dtSnap - 620) / 330));
- if (dtSnap > 950) { showBuilding(); return; } // stop the loop; panes are static
+ // Choreography: snap wild for 950ms (clickDialToEleven timing) → the building
+ // state, still rumbling with the dust flying (in production this covers the
+ // POST /api/spaces round-trip, ~5s — it returns once the space is confirmed
+ // reachable) → done: the rattle cuts out and the confetti flies. The dust
+ // takes a quick bow at the handover.
+ if (phase === "snap" && t - phaseT0 > 950) {
+ phase = "build"; phaseT0 = t;
+ $("pitch").style.display = "none";
+ $("building").style.display = "block";
+ } else if (phase === "build" && t - phaseT0 > P.build) {
+ phase = "done"; phaseT0 = t;
+ $("building").style.display = "none";
+ $("done").style.display = "block";
+ spawnConfetti();
}
+ if (phase === "done" && dust) dust.fade = Math.max(0, 1 - (t - phaseT0) / 600);
paint();
- requestAnimationFrame(tick);
-}
-
-function snapToEleven() {
- // No travel, no ease — one frame it's at 10, the next it's AT eleven.
- snapped = true;
- value = 10;
- pressure = 0;
- snapT0 = performance.now();
-}
-
-// In production this pane covers the POST /api/spaces round-trip (~5s — the API
-// returns only once the space is confirmed reachable). Here a timer stands in.
-let buildTimer = 0;
-function showBuilding() {
- card.style.transform = "";
- if (dust) { dust.fade = 0; stepDust(0); }
- $("pitch").style.display = "none";
- $("building").style.display = "block";
- buildTimer = setTimeout(showDone, P.build);
-}
-
-function showDone() {
- $("building").style.display = "none";
- $("done").style.display = "block";
+ // Park the loop once the show is over (reset restarts it).
+ if (phase === "done" && !confetti.length && (!dust || dust.fade === 0)) {
+ card.style.transform = "";
+ if (dust) stepDust(0); // one clean final frame (also clears settled motes)
+ return;
+ }
+ rafId = requestAnimationFrame(tick);
}
// ---- dragging -------------------------------------------------------------------
@@ -409,14 +449,14 @@ function valueFromPointer(clientX) {
return 1 + Math.min(1, Math.max(0, f / RUN_END)) * 9; // clamped to 1..10
}
function onDown(e) {
- if (snapped) return;
+ if (phase !== "slide") return;
dragging = true;
fader.classList.add("dragging");
cap.setPointerCapture?.(e.pointerId);
onMove(e);
}
function onMove(e) {
- if (!dragging || snapped) return;
+ if (!dragging || phase !== "slide") return;
const r = fader.getBoundingClientRect();
value = valueFromPointer(e.clientX);
// The overpull: at the 10 stop AND still pulling meaningfully past it.
@@ -436,7 +476,7 @@ addEventListener("pointercancel", onUp);
// Keyboard fallback: arrows walk the notches; at 10, holding → for the hold
// time arms eleven (End does not — eleven must be earned).
cap.addEventListener("keydown", (e) => {
- if (snapped) return;
+ if (phase !== "slide") return;
if (e.key === "ArrowRight") { value = Math.min(10, Math.round(value) + 1); overpull = value >= 10; }
else if (e.key === "ArrowLeft") { value = Math.max(1, Math.round(value) - 1); overpull = false; }
});
@@ -444,14 +484,15 @@ cap.addEventListener("keyup", () => { overpull = false; });
// ---- reset + panel ---------------------------------------------------------------
function resetLever() {
- clearTimeout(buildTimer);
- snapped = false; value = 1; pressure = 0; overpull = false;
+ cancelAnimationFrame(rafId); // never two loops — reset can land mid-run
+ phase = "slide"; value = 1; pressure = 0; overpull = false;
+ confetti = [];
if (dust) dust.fade = 1;
$("pitch").style.display = "";
$("building").style.display = "none";
$("done").style.display = "none";
lastT = performance.now();
- requestAnimationFrame(tick);
+ rafId = requestAnimationFrame(tick);
}
$("reset").addEventListener("click", resetLever);
$("p-reset").addEventListener("click", resetLever);
@@ -468,7 +509,7 @@ bind("kick", "kick", (v) => v.toFixed(2)); bind("motes", "motes"); bind("build",
addEventListener("resize", initDust);
initDust();
-requestAnimationFrame(tick);
+rafId = requestAnimationFrame(tick);
</script>
</body>
</html>