// The goes-to-eleven fader: the create-a-space control. A mixing-desk channel // fader the user slides 1→10; holding the pull past 10 for a beat SNAPS it to // eleven, which fires the real create. The card shakes harder as the volume // climbs, stage dust bounces along the bottom of the viewport, the build wait // holds AT eleven, and success cuts to silence + full-screen confetti. // // Feel + physics were iterated in prototypes/space-lever.html and lifted from // the product's boot amp knob (web/static/shell.js): level² hops, gravity 0.62, // kick odds 0.02+level*0.2, CRANKED shake at eleven. Keep the three in step. // // Pure helpers (faderLevel, faderValue) live at module top with no DOM so // they're unit-testable in Node (home/test/fader.test.mjs); mountFader owns // every document reference. const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap const HOLD_MS = 900; // continued pull at 10 before the snap to 11 const SNAP_MS = 950; // the wild window before the building pane appears const GRAVITY = 0.62; // dust gravity (shell.js DUST_GRAVITY) const KICK = 0.2; // dust kick odds scale const SHAKE = 1.7; // base shake amplitude (px) at full level const CRANKED = 2.3; // shake multiplier at eleven // faderValue maps a pointer's fraction along the slot to the 1..10 value // (eleven is not reachable by position — only the snap goes there). export function faderValue(fraction) { return 1 + Math.min(1, Math.max(0, fraction / RUN_END)) * 9; } // faderLevel is the loudness driving shake + dust: silent below ~5, trembling // by 10, PAST 1.0 at the snap and held there through the whole build; done // cuts to zero. export function faderLevel(phase, value, pressure) { if (phase === "snap" || phase === "build") return 1.2; if (phase === "done") return 0; return Math.max(0, (value - 5) / 6) + pressure * 0.25; } // mountFader builds the instrument into the given elements and runs it. // opts: { // fader, meter, — the .fader and .meter containers // shakeEl, — what trembles (the card) // canvas, — full-viewport canvas for dust + confetti // pitch, building, — panes: the slider pitch and the "Hold on…" wait // onEleven, — async: fired AT the snap; the real create call // onReady, onError, — settle callbacks (ready → confirmation pane) // } // Returns { start, stop, reset } — start when the create screen opens, stop // when leaving it (parks the loop, clears the canvas). export function mountFader(opts) { const { fader, meter, shakeEl, canvas, pitch, building, onEleven, onReady, onError } = opts; const doc = fader.ownerDocument; const win = doc.defaultView; const reduced = win.matchMedia("(prefers-reduced-motion: reduce)").matches; // Reading a custom property returns the RAW light-dark(…) token, which canvas // fillStyle can't parse (it silently stays black) — resolve through a real // `color` property instead. function resolveColor(varName) { const el = doc.createElement("span"); el.style.color = `var(${varName})`; doc.body.appendChild(el); const c = win.getComputedStyle(el).color; el.remove(); return c; } // ---- scale: ticks + numbers 1..10 across RUN_END, a gap, then 11 ------------ const cap = fader.querySelector(".cap"); const nums = []; for (let n = 1; n <= 11; n++) { const left = (n === 11 ? 100 : ((n - 1) / 9) * RUN_END * 100) + "%"; const tick = doc.createElement("span"); tick.className = "tick" + (n === 1 || n === 11 ? " major" : ""); tick.style.left = left; fader.appendChild(tick); const num = doc.createElement("span"); num.className = "num" + (n === 11 ? " eleven" : ""); num.textContent = n; num.style.left = left; fader.appendChild(num); nums.push(num); } const leds = []; for (let n = 1; n <= 11; n++) { const led = doc.createElement("span"); led.className = "led"; led.dataset.c = n <= 7 ? "g" : n <= 9 ? "a" : "r"; meter.appendChild(led); leds.push(led); } // ---- stage dust (physics shared with shell.js stepDust) --------------------- let dust = null; function initDust() { if (reduced) return; const dpr = win.devicePixelRatio || 1; const w = win.innerWidth, h = win.innerHeight; canvas.width = w * dpr; canvas.height = h * dpr; const g = canvas.getContext("2d"); if (!g) return; g.scale(dpr, dpr); // Dust contrasts with the floor it sits on: dark specks (muted) on a light // background, light specks (ink) on a dark one — judged by the RESOLVED // body background, so it tracks the page's actual appearance. const ink = resolveColor(darkBG() ? "--ink" : "--muted"); const accent = resolveColor("--accent"); const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({ x: Math.random() * w, y: h - 2 - Math.random() * 2, vx: 0, vy: 0, r: 0.8 + Math.random() * 2.1, c: Math.random() < 0.18 ? accent : ink, a: 0.3 + Math.random() * 0.5, })); dust = { g, w, h, parts, fade: 1 }; } function darkBG() { const rgb = ((win.getComputedStyle(doc.body).backgroundColor || "").match(/\d+/g) || []).map(Number); return rgb.length >= 3 && 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] < 128; } function stepDust(level) { if (!dust) return; const { g, w, h, parts } = dust; g.clearRect(0, 0, w, h); for (const p of parts) { if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * KICK) { const peak = level > 1 ? 0.5 + Math.random() * 0.4 : level * level * (0.12 + Math.random() * 0.22); p.vy = -Math.sqrt(2 * GRAVITY * h * peak); p.vx += (Math.random() - 0.5) * 2.2 * level; } p.vy += GRAVITY; p.y += p.vy; p.x += p.vx; p.vx *= 0.97; if (p.y >= h - 2) { p.y = h - 2; p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; } if (p.x < -4) p.x += w + 8; else if (p.x > w + 4) p.x -= w + 8; g.globalAlpha = p.a * dust.fade; g.fillStyle = p.c; g.beginPath(); g.arc(p.x, p.y, p.r, 0, 7); g.fill(); } g.globalAlpha = 1; } // ---- confetti (the payoff: corner cannons + a sky-wide rain) ---------------- const CONFETTI_GRAVITY = 0.18; let confetti = []; function confettiPiece(over) { return { delay: 0, t: 0, life: 3800, vx: 0, vy: 0, w: 5 + Math.random() * 6, h: 3 + Math.random() * 5, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.35, phase: Math.random() * Math.PI * 2, c: `hsl(${(Math.random() * 360) | 0} 92% ${52 + Math.random() * 14}%)`, ...over, }; } function spawnConfetti() { if (reduced || !dust) return; const { w, h } = dust; confetti = []; for (let i = 0; i < 75; i++) { const left = i % 2 === 0; confetti.push(confettiPiece({ x: left ? -6 : w + 6, y: h * (0.75 + Math.random() * 0.25), vx: (left ? 1 : -1) * (5 + Math.random() * 8), vy: -(11 + Math.random() * 7), delay: Math.random() * 150, })); } for (let i = 0; i < 170; i++) { confetti.push(confettiPiece({ x: Math.random() * w, y: -10 - Math.random() * 40, vx: (Math.random() - 0.5) * 2, vy: 1.5 + Math.random() * 2.5, delay: Math.random() * 1800, life: 4600, })); } } function stepConfetti(dt) { if (!dust || !confetti.length) return; const { g, h } = dust; confetti = confetti.filter((p) => { if (p.delay > 0) { p.delay -= dt; return true; } return (p.t += dt) < p.life && p.y < h + 20; }); for (const p of confetti) { if (p.delay > 0) continue; p.vy = Math.min(p.vy + CONFETTI_GRAVITY, 6.5); p.vx *= 0.99; p.x += p.vx + Math.sin(p.t / 130 + p.phase) * 0.9; p.y += p.vy; p.rot += p.vr; g.save(); g.translate(p.x, p.y); g.rotate(p.rot); const tumble = 0.55 + 0.45 * Math.sin(p.t / 90 + p.phase); g.globalAlpha = Math.min(1, 5 * (1 - p.t / p.life)); g.fillStyle = p.c; g.fillRect((-p.w * tumble) / 2, -p.h / 2, p.w * tumble, p.h); g.restore(); } g.globalAlpha = 1; } // ---- phase machine: idle | slide | snap | build | done ---------------------- let phase = "idle"; let value = 1; let pressure = 0; let dragging = false; let overpull = false; let phaseT0 = 0; let lastT = 0; let rafId = 0; // The create promise settles independently of the snap window; done needs both. let settled = null; // { ok, result | error } function paint() { const past = phase === "snap" || phase === "build" || phase === "done"; const fx = past ? 1 : ((value - 1) / 9) * RUN_END + (pressure > 0 ? pressure * (1 - RUN_END) * 0.35 : 0); cap.style.left = (fx * 100) + "%"; 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 && !past); nums[10].classList.toggle("hit", past); const hot = faderLevel(phase, value, pressure); const flicker = !reduced && hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0; const lit = past ? 11 : Math.round(value) + flicker; leds.forEach((led, i) => { const on = i < lit; led.className = "led" + (on ? " on-" + led.dataset.c : "") + ((phase === "snap" || phase === "build") && i === 10 ? " fury" : ""); }); } function tick(t) { const dt = Math.min(50, t - lastT || 16); lastT = t; if (phase === "slide") { if (overpull && value >= 9.99) pressure = Math.min(1, pressure + dt / HOLD_MS); else pressure = Math.max(0, pressure - dt / 300); if (pressure >= 1) snapToEleven(); } const level = faderLevel(phase, value, pressure); stepDust(level); stepConfetti(dt); if (!reduced && level > 0) { const mult = phase === "snap" || phase === "build" ? CRANKED : 1; const a = SHAKE * level * mult; shakeEl.style.transform = `translate(${((Math.random() - .5) * 2 * a).toFixed(2)}px, ${((Math.random() - .5) * 2 * a).toFixed(2)}px)` + ` rotate(${((Math.random() - .5) * 1.6 * level * mult).toFixed(2)}deg)`; } else { shakeEl.style.transform = ""; } // snap → build once the wild window has played; done/error once the create // settles (whichever is later, so a fast create still gets its theatre). if (phase === "snap" && t - phaseT0 > SNAP_MS) { phase = "build"; phaseT0 = t; pitch.classList.add("hidden"); building.classList.remove("hidden"); } if (phase === "build" && settled) { const s = settled; settled = null; building.classList.add("hidden"); if (s.ok) { phase = "done"; phaseT0 = t; spawnConfetti(); onReady(s.result); } else { // The volume comes down, the fader resets, and the pitch returns so the // user can slide again once they've read what went wrong. phase = "slide"; value = 1; pressure = 0; overpull = false; pitch.classList.remove("hidden"); onError(s.error); } } if (phase === "done" && dust) dust.fade = Math.max(0, 1 - (t - phaseT0) / 600); paint(); // Park after the show (confetti drained, dust bowed); start() resumes. if (phase === "done" && !confetti.length && (!dust || dust.fade === 0)) { shakeEl.style.transform = ""; if (dust) stepDust(0); rafId = 0; return; } rafId = win.requestAnimationFrame(tick); } function snapToEleven() { phase = "snap"; phaseT0 = lastT; value = 10; pressure = 0; // Fire the real create NOW — the wild window overlaps the network time. Promise.resolve() .then(onEleven) .then((result) => { settled = { ok: true, result }; }) .catch((error) => { settled = { ok: false, error }; }); } // ---- input ------------------------------------------------------------------- function fractionFromPointer(clientX) { const r = fader.getBoundingClientRect(); return (clientX - r.left) / r.width; } function onDown(e) { if (phase !== "slide") return; dragging = true; fader.classList.add("dragging"); cap.setPointerCapture?.(e.pointerId); onMove(e); } function onMove(e) { if (!dragging || phase !== "slide") return; const r = fader.getBoundingClientRect(); value = faderValue(fractionFromPointer(e.clientX)); overpull = value >= 9.99 && e.clientX > r.left + r.width * RUN_END + 14; } function onUp() { dragging = false; overpull = false; fader.classList.remove("dragging"); } cap.addEventListener("pointerdown", onDown); fader.addEventListener("pointerdown", onDown); win.addEventListener("pointermove", onMove); win.addEventListener("pointerup", onUp); win.addEventListener("pointercancel", onUp); cap.addEventListener("keydown", (e) => { if (phase !== "slide") return; if (e.key === "ArrowRight") { value = Math.min(10, Math.round(value) + 1); overpull = value >= 10; e.preventDefault(); } else if (e.key === "ArrowLeft") { value = Math.max(1, Math.round(value) - 1); overpull = false; e.preventDefault(); } }); cap.addEventListener("keyup", () => { overpull = false; }); // ---- lifecycle ----------------------------------------------------------------- function start() { if (phase === "idle") reset(); initDust(); if (!rafId) { lastT = 0; rafId = win.requestAnimationFrame(tick); } } function stop() { if (rafId) { win.cancelAnimationFrame(rafId); rafId = 0; } shakeEl.style.transform = ""; confetti = []; if (dust) { dust.g.clearRect(0, 0, dust.w, dust.h); dust = null; } phase = "idle"; } function reset() { phase = "slide"; value = 1; pressure = 0; overpull = false; settled = null; confetti = []; if (dust) dust.fade = 1; pitch.classList.remove("hidden"); building.classList.add("hidden"); paint(); } win.addEventListener("resize", () => { if (rafId) initDust(); }); return { start, stop, reset }; }