eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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
15const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap
16const HOLD_MS = 900; // continued pull at 10 before the snap to 11
17const SNAP_MS = 950; // the wild window before the building pane appears
18const GRAVITY = 0.62; // dust gravity (shell.js DUST_GRAVITY)
19const KICK = 0.2; // dust kick odds scale
20const SHAKE = 1.7; // base shake amplitude (px) at full level
21const 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).
25export 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.
32export 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).
49export 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