| 1 | // The goes-to-eleven fader: the create-a-space control. A mixing-desk channel |
| 2 | // fader the user slides 1→10; holding the pull past 10 for a beat SNAPS it to |
| 3 | // eleven, which fires the real create. The card shakes harder as the volume |
| 4 | // climbs, stage dust bounces along the bottom of the viewport, the build wait |
| 5 | // holds AT eleven, and success cuts to silence + full-screen confetti. |
| 6 | // |
| 7 | // Feel + physics were iterated in prototypes/space-lever.html and lifted from |
| 8 | // the product's boot amp knob (web/static/shell.js): level² hops, gravity 0.62, |
| 9 | // kick odds 0.02+level*0.2, CRANKED shake at eleven. Keep the three in step. |
| 10 | // |
| 11 | // Pure helpers (faderLevel, faderValue) live at module top with no DOM so |
| 12 | // they're unit-testable in Node (home/test/fader.test.mjs); mountFader owns |
| 13 | // every document reference. |
| 14 | |
| 15 | const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap |
| 16 | const HOLD_MS = 900; // continued pull at 10 before the snap to 11 |
| 17 | const SNAP_MS = 950; // the wild window before the building pane appears |
| 18 | const GRAVITY = 0.62; // dust gravity (shell.js DUST_GRAVITY) |
| 19 | const KICK = 0.2; // dust kick odds scale |
| 20 | const SHAKE = 1.7; // base shake amplitude (px) at full level |
| 21 | const CRANKED = 2.3; // shake multiplier at eleven |
| 22 | |
| 23 | // faderValue maps a pointer's fraction along the slot to the 1..10 value |
| 24 | // (eleven is not reachable by position — only the snap goes there). |
| 25 | export function faderValue(fraction) { |
| 26 | return 1 + Math.min(1, Math.max(0, fraction / RUN_END)) * 9; |
| 27 | } |
| 28 | |
| 29 | // faderLevel is the loudness driving shake + dust: silent below ~5, trembling |
| 30 | // by 10, PAST 1.0 at the snap and held there through the whole build; done |
| 31 | // cuts to zero. |
| 32 | export function faderLevel(phase, value, pressure) { |
| 33 | if (phase === "snap" || phase === "build") return 1.2; |
| 34 | if (phase === "done") return 0; |
| 35 | return Math.max(0, (value - 5) / 6) + pressure * 0.25; |
| 36 | } |
| 37 | |
| 38 | // mountFader builds the instrument into the given elements and runs it. |
| 39 | // opts: { |
| 40 | // fader, meter, — the .fader and .meter containers |
| 41 | // shakeEl, — what trembles (the card) |
| 42 | // canvas, — full-viewport canvas for dust + confetti |
| 43 | // pitch, building, — panes: the slider pitch and the "Hold on…" wait |
| 44 | // onEleven, — async: fired AT the snap; the real create call |
| 45 | // onReady, onError, — settle callbacks (ready → confirmation pane) |
| 46 | // } |
| 47 | // Returns { start, stop, reset } — start when the create screen opens, stop |
| 48 | // when leaving it (parks the loop, clears the canvas). |
| 49 | export function mountFader(opts) { |
| 50 | const { fader, meter, shakeEl, canvas, pitch, building, onEleven, onReady, onError } = opts; |
| 51 | const doc = fader.ownerDocument; |
| 52 | const win = doc.defaultView; |
| 53 | const reduced = win.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 54 | |
| 55 | // Reading a custom property returns the RAW light-dark(…) token, which canvas |
| 56 | // fillStyle can't parse (it silently stays black) — resolve through a real |
| 57 | // `color` property instead. |
| 58 | function resolveColor(varName) { |
| 59 | const el = doc.createElement("span"); |
| 60 | el.style.color = `var(${varName})`; |
| 61 | doc.body.appendChild(el); |
| 62 | const c = win.getComputedStyle(el).color; |
| 63 | el.remove(); |
| 64 | return c; |
| 65 | } |
| 66 | |
| 67 | // ---- scale: ticks + numbers 1..10 across RUN_END, a gap, then 11 ------------ |
| 68 | const cap = fader.querySelector(".cap"); |
| 69 | const nums = []; |
| 70 | for (let n = 1; n <= 11; n++) { |
| 71 | const left = (n === 11 ? 100 : ((n - 1) / 9) * RUN_END * 100) + "%"; |
| 72 | const tick = doc.createElement("span"); |
| 73 | tick.className = "tick" + (n === 1 || n === 11 ? " major" : ""); |
| 74 | tick.style.left = left; |
| 75 | fader.appendChild(tick); |
| 76 | const num = doc.createElement("span"); |
| 77 | num.className = "num" + (n === 11 ? " eleven" : ""); |
| 78 | num.textContent = n; |
| 79 | num.style.left = left; |
| 80 | fader.appendChild(num); |
| 81 | nums.push(num); |
| 82 | } |
| 83 | const leds = []; |
| 84 | for (let n = 1; n <= 11; n++) { |
| 85 | const led = doc.createElement("span"); |
| 86 | led.className = "led"; |
| 87 | led.dataset.c = n <= 7 ? "g" : n <= 9 ? "a" : "r"; |
| 88 | meter.appendChild(led); |
| 89 | leds.push(led); |
| 90 | } |
| 91 | |
| 92 | // ---- stage dust (physics shared with shell.js stepDust) --------------------- |
| 93 | let dust = null; |
| 94 | function initDust() { |
| 95 | if (reduced) return; |
| 96 | const dpr = win.devicePixelRatio || 1; |
| 97 | const w = win.innerWidth, h = win.innerHeight; |
| 98 | canvas.width = w * dpr; |
| 99 | canvas.height = h * dpr; |
| 100 | const g = canvas.getContext("2d"); |
| 101 | if (!g) return; |
| 102 | g.scale(dpr, dpr); |
| 103 | // Dust contrasts with the floor it sits on: dark specks (muted) on a light |
| 104 | // background, light specks (ink) on a dark one — judged by the RESOLVED |
| 105 | // body background, so it tracks the page's actual appearance. |
| 106 | const ink = resolveColor(darkBG() ? "--ink" : "--muted"); |
| 107 | const accent = resolveColor("--accent"); |
| 108 | const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({ |
| 109 | x: Math.random() * w, |
| 110 | y: h - 2 - Math.random() * 2, |
| 111 | vx: 0, vy: 0, |
| 112 | r: 0.8 + Math.random() * 2.1, |
| 113 | c: Math.random() < 0.18 ? accent : ink, |
| 114 | a: 0.3 + Math.random() * 0.5, |
| 115 | })); |
| 116 | dust = { g, w, h, parts, fade: 1 }; |
| 117 | } |
| 118 | function darkBG() { |
| 119 | const rgb = ((win.getComputedStyle(doc.body).backgroundColor || "").match(/\d+/g) || []).map(Number); |
| 120 | return rgb.length >= 3 && 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] < 128; |
| 121 | } |
| 122 | function stepDust(level) { |
| 123 | if (!dust) return; |
| 124 | const { g, w, h, parts } = dust; |
| 125 | g.clearRect(0, 0, w, h); |
| 126 | for (const p of parts) { |
| 127 | if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * KICK) { |
| 128 | const peak = level > 1 ? 0.5 + Math.random() * 0.4 |
| 129 | : level * level * (0.12 + Math.random() * 0.22); |
| 130 | p.vy = -Math.sqrt(2 * GRAVITY * h * peak); |
| 131 | p.vx += (Math.random() - 0.5) * 2.2 * level; |
| 132 | } |
| 133 | p.vy += GRAVITY; p.y += p.vy; p.x += p.vx; p.vx *= 0.97; |
| 134 | if (p.y >= h - 2) { p.y = h - 2; p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; } |
| 135 | if (p.x < -4) p.x += w + 8; else if (p.x > w + 4) p.x -= w + 8; |
| 136 | g.globalAlpha = p.a * dust.fade; |
| 137 | g.fillStyle = p.c; |
| 138 | g.beginPath(); g.arc(p.x, p.y, p.r, 0, 7); g.fill(); |
| 139 | } |
| 140 | g.globalAlpha = 1; |
| 141 | } |
| 142 | |
| 143 | // ---- confetti (the payoff: corner cannons + a sky-wide rain) ---------------- |
| 144 | const CONFETTI_GRAVITY = 0.18; |
| 145 | let confetti = []; |
| 146 | function confettiPiece(over) { |
| 147 | return { |
| 148 | delay: 0, t: 0, life: 3800, |
| 149 | vx: 0, vy: 0, |
| 150 | w: 5 + Math.random() * 6, |
| 151 | h: 3 + Math.random() * 5, |
| 152 | rot: Math.random() * Math.PI, |
| 153 | vr: (Math.random() - 0.5) * 0.35, |
| 154 | phase: Math.random() * Math.PI * 2, |
| 155 | c: `hsl(${(Math.random() * 360) | 0} 92% ${52 + Math.random() * 14}%)`, |
| 156 | ...over, |
| 157 | }; |
| 158 | } |
| 159 | function spawnConfetti() { |
| 160 | if (reduced || !dust) return; |
| 161 | const { w, h } = dust; |
| 162 | confetti = []; |
| 163 | for (let i = 0; i < 75; i++) { |
| 164 | const left = i % 2 === 0; |
| 165 | confetti.push(confettiPiece({ |
| 166 | x: left ? -6 : w + 6, |
| 167 | y: h * (0.75 + Math.random() * 0.25), |
| 168 | vx: (left ? 1 : -1) * (5 + Math.random() * 8), |
| 169 | vy: -(11 + Math.random() * 7), |
| 170 | delay: Math.random() * 150, |
| 171 | })); |
| 172 | } |
| 173 | for (let i = 0; i < 170; i++) { |
| 174 | confetti.push(confettiPiece({ |
| 175 | x: Math.random() * w, |
| 176 | y: -10 - Math.random() * 40, |
| 177 | vx: (Math.random() - 0.5) * 2, |
| 178 | vy: 1.5 + Math.random() * 2.5, |
| 179 | delay: Math.random() * 1800, |
| 180 | life: 4600, |
| 181 | })); |
| 182 | } |
| 183 | } |
| 184 | function stepConfetti(dt) { |
| 185 | if (!dust || !confetti.length) return; |
| 186 | const { g, h } = dust; |
| 187 | confetti = confetti.filter((p) => { |
| 188 | if (p.delay > 0) { p.delay -= dt; return true; } |
| 189 | return (p.t += dt) < p.life && p.y < h + 20; |
| 190 | }); |
| 191 | for (const p of confetti) { |
| 192 | if (p.delay > 0) continue; |
| 193 | p.vy = Math.min(p.vy + CONFETTI_GRAVITY, 6.5); |
| 194 | p.vx *= 0.99; |
| 195 | p.x += p.vx + Math.sin(p.t / 130 + p.phase) * 0.9; |
| 196 | p.y += p.vy; |
| 197 | p.rot += p.vr; |
| 198 | g.save(); |
| 199 | g.translate(p.x, p.y); |
| 200 | g.rotate(p.rot); |
| 201 | const tumble = 0.55 + 0.45 * Math.sin(p.t / 90 + p.phase); |
| 202 | g.globalAlpha = Math.min(1, 5 * (1 - p.t / p.life)); |
| 203 | g.fillStyle = p.c; |
| 204 | g.fillRect((-p.w * tumble) / 2, -p.h / 2, p.w * tumble, p.h); |
| 205 | g.restore(); |
| 206 | } |
| 207 | g.globalAlpha = 1; |
| 208 | } |
| 209 | |
| 210 | // ---- phase machine: idle | slide | snap | build | done ---------------------- |
| 211 | let phase = "idle"; |
| 212 | let value = 1; |
| 213 | let pressure = 0; |
| 214 | let dragging = false; |
| 215 | let overpull = false; |
| 216 | let phaseT0 = 0; |
| 217 | let lastT = 0; |
| 218 | let rafId = 0; |
| 219 | // The create promise settles independently of the snap window; done needs both. |
| 220 | let settled = null; // { ok, result | error } |
| 221 | |
| 222 | function paint() { |
| 223 | const past = phase === "snap" || phase === "build" || phase === "done"; |
| 224 | const fx = past ? 1 |
| 225 | : ((value - 1) / 9) * RUN_END + (pressure > 0 ? pressure * (1 - RUN_END) * 0.35 : 0); |
| 226 | cap.style.left = (fx * 100) + "%"; |
| 227 | cap.setAttribute("aria-valuenow", past ? 11 : Math.round(value)); |
| 228 | for (let n = 1; n <= 10; n++) nums[n - 1].classList.toggle("lit", value >= n - 0.01); |
| 229 | nums[10].classList.toggle("arming", pressure > 0.05 && !past); |
| 230 | nums[10].classList.toggle("hit", past); |
| 231 | const hot = faderLevel(phase, value, pressure); |
| 232 | const flicker = !reduced && hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0; |
| 233 | const lit = past ? 11 : Math.round(value) + flicker; |
| 234 | leds.forEach((led, i) => { |
| 235 | const on = i < lit; |
| 236 | led.className = "led" + (on ? " on-" + led.dataset.c : "") + |
| 237 | ((phase === "snap" || phase === "build") && i === 10 ? " fury" : ""); |
| 238 | }); |
| 239 | } |
| 240 | |
| 241 | function tick(t) { |
| 242 | const dt = Math.min(50, t - lastT || 16); |
| 243 | lastT = t; |
| 244 | |
| 245 | if (phase === "slide") { |
| 246 | if (overpull && value >= 9.99) pressure = Math.min(1, pressure + dt / HOLD_MS); |
| 247 | else pressure = Math.max(0, pressure - dt / 300); |
| 248 | if (pressure >= 1) snapToEleven(); |
| 249 | } |
| 250 | |
| 251 | const level = faderLevel(phase, value, pressure); |
| 252 | stepDust(level); |
| 253 | stepConfetti(dt); |
| 254 | |
| 255 | if (!reduced && level > 0) { |
| 256 | const mult = phase === "snap" || phase === "build" ? CRANKED : 1; |
| 257 | const a = SHAKE * level * mult; |
| 258 | shakeEl.style.transform = |
| 259 | `translate(${((Math.random() - .5) * 2 * a).toFixed(2)}px, ${((Math.random() - .5) * 2 * a).toFixed(2)}px)` + |
| 260 | ` rotate(${((Math.random() - .5) * 1.6 * level * mult).toFixed(2)}deg)`; |
| 261 | } else { |
| 262 | shakeEl.style.transform = ""; |
| 263 | } |
| 264 | |
| 265 | // snap → build once the wild window has played; done/error once the create |
| 266 | // settles (whichever is later, so a fast create still gets its theatre). |
| 267 | if (phase === "snap" && t - phaseT0 > SNAP_MS) { |
| 268 | phase = "build"; phaseT0 = t; |
| 269 | pitch.classList.add("hidden"); |
| 270 | building.classList.remove("hidden"); |
| 271 | } |
| 272 | if (phase === "build" && settled) { |
| 273 | const s = settled; settled = null; |
| 274 | building.classList.add("hidden"); |
| 275 | if (s.ok) { |
| 276 | phase = "done"; phaseT0 = t; |
| 277 | spawnConfetti(); |
| 278 | onReady(s.result); |
| 279 | } else { |
| 280 | // The volume comes down, the fader resets, and the pitch returns so the |
| 281 | // user can slide again once they've read what went wrong. |
| 282 | phase = "slide"; value = 1; pressure = 0; overpull = false; |
| 283 | pitch.classList.remove("hidden"); |
| 284 | onError(s.error); |
| 285 | } |
| 286 | } |
| 287 | if (phase === "done" && dust) dust.fade = Math.max(0, 1 - (t - phaseT0) / 600); |
| 288 | |
| 289 | paint(); |
| 290 | // Park after the show (confetti drained, dust bowed); start() resumes. |
| 291 | if (phase === "done" && !confetti.length && (!dust || dust.fade === 0)) { |
| 292 | shakeEl.style.transform = ""; |
| 293 | if (dust) stepDust(0); |
| 294 | rafId = 0; |
| 295 | return; |
| 296 | } |
| 297 | rafId = win.requestAnimationFrame(tick); |
| 298 | } |
| 299 | |
| 300 | function snapToEleven() { |
| 301 | phase = "snap"; |
| 302 | phaseT0 = lastT; |
| 303 | value = 10; |
| 304 | pressure = 0; |
| 305 | // Fire the real create NOW — the wild window overlaps the network time. |
| 306 | Promise.resolve() |
| 307 | .then(onEleven) |
| 308 | .then((result) => { settled = { ok: true, result }; }) |
| 309 | .catch((error) => { settled = { ok: false, error }; }); |
| 310 | } |
| 311 | |
| 312 | // ---- input ------------------------------------------------------------------- |
| 313 | function fractionFromPointer(clientX) { |
| 314 | const r = fader.getBoundingClientRect(); |
| 315 | return (clientX - r.left) / r.width; |
| 316 | } |
| 317 | function onDown(e) { |
| 318 | if (phase !== "slide") return; |
| 319 | dragging = true; |
| 320 | fader.classList.add("dragging"); |
| 321 | cap.setPointerCapture?.(e.pointerId); |
| 322 | onMove(e); |
| 323 | } |
| 324 | function onMove(e) { |
| 325 | if (!dragging || phase !== "slide") return; |
| 326 | const r = fader.getBoundingClientRect(); |
| 327 | value = faderValue(fractionFromPointer(e.clientX)); |
| 328 | overpull = value >= 9.99 && e.clientX > r.left + r.width * RUN_END + 14; |
| 329 | } |
| 330 | function onUp() { |
| 331 | dragging = false; |
| 332 | overpull = false; |
| 333 | fader.classList.remove("dragging"); |
| 334 | } |
| 335 | cap.addEventListener("pointerdown", onDown); |
| 336 | fader.addEventListener("pointerdown", onDown); |
| 337 | win.addEventListener("pointermove", onMove); |
| 338 | win.addEventListener("pointerup", onUp); |
| 339 | win.addEventListener("pointercancel", onUp); |
| 340 | cap.addEventListener("keydown", (e) => { |
| 341 | if (phase !== "slide") return; |
| 342 | if (e.key === "ArrowRight") { value = Math.min(10, Math.round(value) + 1); overpull = value >= 10; e.preventDefault(); } |
| 343 | else if (e.key === "ArrowLeft") { value = Math.max(1, Math.round(value) - 1); overpull = false; e.preventDefault(); } |
| 344 | }); |
| 345 | cap.addEventListener("keyup", () => { overpull = false; }); |
| 346 | |
| 347 | // ---- lifecycle ----------------------------------------------------------------- |
| 348 | function start() { |
| 349 | if (phase === "idle") reset(); |
| 350 | initDust(); |
| 351 | if (!rafId) { lastT = 0; rafId = win.requestAnimationFrame(tick); } |
| 352 | } |
| 353 | function stop() { |
| 354 | if (rafId) { win.cancelAnimationFrame(rafId); rafId = 0; } |
| 355 | shakeEl.style.transform = ""; |
| 356 | confetti = []; |
| 357 | if (dust) { dust.g.clearRect(0, 0, dust.w, dust.h); dust = null; } |
| 358 | phase = "idle"; |
| 359 | } |
| 360 | function reset() { |
| 361 | phase = "slide"; value = 1; pressure = 0; overpull = false; settled = null; |
| 362 | confetti = []; |
| 363 | if (dust) dust.fade = 1; |
| 364 | pitch.classList.remove("hidden"); |
| 365 | building.classList.add("hidden"); |
| 366 | paint(); |
| 367 | } |
| 368 | win.addEventListener("resize", () => { if (rafId) initDust(); }); |
| 369 | |
| 370 | return { start, stop, reset }; |
| 371 | } |
| 372 | |