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>
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> |