eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1<!doctype html>
2<!-- πŸ€– Prototype: the "goes to eleven" create-a-space fader.
3
4 A local practice page β€” open it straight from disk, no server, nothing is
5 provisioned. The plan: iterate the FEEL here (drag physics, shake, dust,
6 the 10β†’11 snap), then port the blessed version into home's create screen
7 (home/web/pages/index.html + home.js), wired so that reaching ELEVEN is
8 what fires POST /api/spaces.
9
10 Styled as a mixing-desk channel fader: a recessed slot, a rectangular cap
11 with the classic centre stripe, a tick scale 1–11, and a VU-style LED
12 ladder that dances with the level. The physics are lifted from the
13 product's boot amp knob (web/static/shell.js: stepDust/dialPaint/
14 clickDialToEleven): levelΒ² hops, gravity 0.62, kick odds 0.02+level*0.2,
15 CRANKED=2.3 at eleven. Tweak everything live in the panel (bottom right).
16-->
17<html lang="en">
18<head>
19<meta charset="utf-8">
20<meta name="viewport" content="width=device-width, initial-scale=1">
21<title>Space fader β€” prototype</title>
22<style>
23 /* Tokens copied from home.css (classic-neutral, indigo accent), plus VU
24 colours β€” the one place real desk vernacular (green/amber/red) beats brand. */
25 :root {
26 color-scheme: light dark;
27 --bg: light-dark(#f6f7fb, #15171c);
28 --panel: light-dark(#ffffff, #1d2026);
29 --ink: light-dark(#1c2333, #e7e9f0);
30 --muted: light-dark(#5a6170, #9aa1b3);
31 --border: light-dark(#e7e9f0, rgba(255,255,255,.10));
32 --accent: light-dark(#5666f0, #7d8bff);
33 --accent-ink: light-dark(#ffffff, #0a0e2a);
34 --quiet: light-dark(#eef0f6, #2a2e38);
35 --shadow: light-dark(rgba(20,30,80,.10), rgba(0,0,0,.55));
36 --ok: light-dark(#237a41, #58b97e);
37 --led-g: light-dark(#31a24c, #3fd063);
38 --led-a: light-dark(#e8a23a, #f0b45a);
39 --led-r: light-dark(#e5484d, #ff5d63);
40 --slot: light-dark(#d3d7e2, #0e1014);
41 }
42 * { box-sizing: border-box; }
43 body {
44 margin: 0; min-height: 100svh; display: grid; place-items: center;
45 font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
46 background: var(--bg); color: var(--ink);
47 overflow: hidden; /* dust flies the whole viewport; no scrollbars */
48 }
49 canvas#dust { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; }
50
51 .card {
52 position: relative; z-index: 1;
53 background: var(--panel); border: 1px solid var(--border); border-radius: 24px;
54 padding: 28px; width: min(440px, calc(100vw - 32px));
55 box-shadow: 0 12px 40px var(--shadow); text-align: center;
56 will-change: transform;
57 /* The card is an instrument, not a document: a drag that wanders off the
58 fader must never start selecting the headline mid-slide. */
59 -webkit-user-select: none; user-select: none;
60 -webkit-touch-callout: none;
61 }
62 h1 { font-size: 23px; font-weight: 700; line-height: 1.2; letter-spacing: -0.015em; margin: 4px 0 6px; }
63 p.sub { color: var(--muted); font-size: 14px; margin: 0 0 22px; }
64
65 /* ---- the channel strip (no plate β€” the fader sits right on the card) ----- */
66 .strip { padding: 4px 2px 10px; }
67 /* VU ladder: one LED per notch. Unlit LEDs are faint wells; lit ones glow in
68 desk colours β€” green through 7, amber 8–9, red at 10, and ELEVEN is red and
69 furious. A touch of flicker while the signal's hot sells the VU. */
70 .meter { display: flex; gap: 4px; margin: 0 2px 16px; }
71 .led {
72 flex: 1; height: 9px; border-radius: 2px;
73 background: color-mix(in srgb, var(--ink) 10%, transparent);
74 box-shadow: inset 0 1px 1px rgba(0,0,0,.18);
75 transition: background .06s;
76 }
77 .led.on-g { background: var(--led-g); box-shadow: 0 0 6px color-mix(in srgb, var(--led-g) 60%, transparent); }
78 .led.on-a { background: var(--led-a); box-shadow: 0 0 6px color-mix(in srgb, var(--led-a) 60%, transparent); }
79 .led.on-r { background: var(--led-r); box-shadow: 0 0 8px color-mix(in srgb, var(--led-r) 70%, transparent); }
80 @keyframes led-fury { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
81 .led.fury { animation: led-fury .14s linear infinite; }
82 /* Reduced motion: no strobe, no colour/scale transitions β€” states still
83 change, they just change instantly. (The dust, confetti, shake, and VU
84 flicker are skipped in the script under the same query.) */
85 @media (prefers-reduced-motion: reduce) {
86 .led.fury { animation: none; }
87 .led, .num.eleven { transition: none; }
88 }
89
90 /* The fader: a recessed slot, ticks above it, numbers below, and the cap. */
91 .fader { position: relative; height: 46px; margin: 0 2px; touch-action: none; cursor: grab; }
92 .fader.dragging { cursor: grabbing; }
93 .slot {
94 position: absolute; left: 0; right: 0; top: 50%; height: 6px; translate: 0 -50%;
95 border-radius: 3px; background: var(--slot);
96 box-shadow: inset 0 1px 3px rgba(0,0,0,.35), inset 0 -1px 0 rgba(255,255,255,.25);
97 }
98 .tick {
99 position: absolute; top: 4px; width: 1.5px; height: 7px; translate: -50% 0;
100 background: color-mix(in srgb, var(--muted) 55%, transparent); border-radius: 1px;
101 }
102 .tick.major { height: 10px; top: 1px; }
103 .num {
104 position: absolute; top: 100%; translate: -50% 0; padding-top: 2px;
105 font-size: 11px; font-weight: 600; color: var(--muted);
106 font-variant-numeric: tabular-nums;
107 }
108 .num.lit { color: var(--ink); }
109 .num.eleven { font-weight: 800; transition: color .12s, transform .12s; }
110 .num.eleven.arming { color: var(--led-r); }
111 .num.eleven.hit { color: var(--led-r); transform: scale(1.4); }
112
113 /* The cap: rectangular, brushed, with the classic centre stripe. */
114 .cap {
115 position: absolute; top: 50%; translate: -50% -50%;
116 width: 30px; height: 44px; border-radius: 6px;
117 background: linear-gradient(180deg,
118 light-dark(#ffffff, #343946) 0%,
119 light-dark(#e7eaf1, #262a33) 48%,
120 light-dark(#d9dde8, #1e222a) 52%,
121 light-dark(#eef0f6, #2b3039) 100%);
122 border: 1px solid light-dark(#c4c9d6, #0c0e12);
123 box-shadow: 0 3px 8px var(--shadow), inset 0 1px 0 rgba(255,255,255,.55);
124 cursor: grab;
125 }
126 .cap::before, .cap::after { content: ""; position: absolute; left: 4px; right: 4px; height: 1px;
127 background: light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); }
128 .cap::before { top: 9px; box-shadow: 0 4px 0 light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); }
129 .cap::after { bottom: 9px; box-shadow: 0 -4px 0 light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); }
130 .stripe { /* the centre line every fader cap wears */
131 position: absolute; top: 6px; bottom: 6px; left: 50%; width: 3px; translate: -50% 0;
132 border-radius: 2px; background: var(--accent); z-index: 1;
133 }
134 .fader.dragging .cap { cursor: grabbing; }
135 .cap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
136
137 /* building pane: shown while the instance provisions (~5s in production β€”
138 the API returns only once the space is confirmed reachable) */
139 #building { display: none; padding: 18px 0 6px; }
140 .build-spin {
141 width: 34px; height: 34px; margin: 4px auto 14px; border-radius: 50%;
142 border: 3px solid color-mix(in srgb, var(--ink) 12%, transparent);
143 border-top-color: var(--accent);
144 animation: spin .7s linear infinite;
145 }
146 @keyframes spin { to { transform: rotate(360deg); } }
147 #building h2 { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
148 #building p { color: var(--muted); font-size: 14px; margin: 0; }
149 @media (prefers-reduced-motion: reduce) { .build-spin { animation: none; opacity: .5; } }
150
151 /* success pane (single-serve confirmation mock) */
152 #done { display: none; }
153 a.btn-link {
154 display: block; font: inherit; font-weight: 600; text-align: center;
155 text-decoration: none; border-radius: 999px; padding: 11px 18px;
156 background: var(--accent); color: var(--accent-ink); line-height: 1.2;
157 margin-bottom: 10px;
158 }
159 a.btn-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
160 .ok-check {
161 width: 46px; height: 46px; margin: 6px auto 12px; border-radius: 50%;
162 display: grid; place-items: center; background: var(--ok); color: #fff;
163 }
164 .ok-check svg { width: 22px; height: 22px; }
165 #done h2 { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
166 #done p { color: var(--muted); font-size: 14px; margin: 0 0 16px; }
167 button.quiet {
168 width: 100%; font: inherit; font-weight: 600; cursor: pointer;
169 background: var(--quiet); color: var(--ink); border: none; border-radius: 999px; padding: 11px 18px;
170 }
171
172 /* ---- tweakables panel (dev only) ---------------------------------------- */
173 #panel {
174 position: fixed; right: 12px; bottom: 12px; z-index: 5;
175 background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
176 padding: 10px 12px; font-size: 12px; color: var(--muted);
177 box-shadow: 0 6px 20px var(--shadow); width: 230px;
178 }
179 #panel label { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin: 4px 0; }
180 #panel input[type=range] { width: 110px; accent-color: var(--accent); }
181 #panel .v { min-width: 34px; text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
182 #panel button { margin-top: 6px; width: 100%; font: inherit; font-size: 12px; border: none; border-radius: 8px; padding: 5px; background: var(--quiet); color: var(--ink); cursor: pointer; }
183</style>
184</head>
185<body>
186<canvas id="dust"></canvas>
187
188<main class="card" id="card">
189 <div id="pitch">
190 <h1>Slide to create</h1>
191 <p class="sub">Your new space will be created immediately.</p>
192 <div class="strip">
193 <div class="meter" id="meter"></div>
194 <div class="fader" id="fader">
195 <div class="slot"></div>
196 <div class="cap" id="cap" role="slider" aria-label="Slide to create"
197 aria-valuemin="1" aria-valuemax="11" aria-valuenow="1" tabindex="0"><div class="stripe"></div></div>
198 </div>
199 </div>
200 </div>
201
202 <div id="building">
203 <div class="build-spin" aria-hidden="true"></div>
204 <h2>Hold on…</h2>
205 <p>Building your space</p>
206 </div>
207
208 <div id="done">
209 <div class="ok-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div>
210 <h2>Your space is ready</h2>
211 <p>(Prototype β€” nothing was provisioned. In production this pane carries the one-time setup link.)</p>
212 <a class="btn-link" href="#" onclick="return false">Continue to new space β†’</a>
213 <button class="quiet" id="reset">Slide it again</button>
214 </div>
215</main>
216
217<div id="panel">
218 <strong style="color:var(--ink)">Feel</strong>
219 <label>hold→11 <input id="p-hold" type="range" min="200" max="2500" step="50" value="900"><span class="v" id="v-hold">900</span></label>
220 <label>shake <input id="p-shake" type="range" min="0" max="4" step="0.1" value="1.7"><span class="v" id="v-shake">1.7</span></label>
221 <label>cranked <input id="p-crank" type="range" min="1" max="5" step="0.1" value="2.3"><span class="v" id="v-crank">2.3</span></label>
222 <label>dust kick <input id="p-kick" type="range" min="0" max="0.6" step="0.02" value="0.2"><span class="v" id="v-kick">0.20</span></label>
223 <label>motes <input id="p-motes" type="range" min="0" max="220" step="10" value="90"><span class="v" id="v-motes">90</span></label>
224 <label>build ms <input id="p-build" type="range" min="500" max="9000" step="250" value="5000"><span class="v" id="v-build">5000</span></label>
225 <button id="p-reset">Reset fader</button>
226</div>
227
228<script>
229// ---- tweakables (bound to the panel) -----------------------------------------
230const P = {
231 hold: 900, // ms of continued pull at 10 before the snap to 11
232 shake: 1.7, // base shake amplitude (px) at full level β€” shell.js used 1.7
233 crank: 2.3, // shake multiplier AT eleven β€” shell.js CRANKED
234 kick: 0.2, // dust kick odds scale β€” shell.js used level*0.2
235 motes: 90, // particle count
236 build: 5000, // ms the mock "building your space" state shows (~real create time)
237};
238const GRAVITY = 0.62; // shell.js DUST_GRAVITY
239const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap
240const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
241const $ = (id) => document.getElementById(id);
242
243// resolveColor turns a CSS variable into a canvas-usable "rgb(…)": assigning it
244// to a real element's `color` is what makes the browser fully resolve it
245// (light-dark() included) before we read it back.
246function resolveColor(varName) {
247 const el = document.createElement("span");
248 el.style.color = `var(${varName})`;
249 document.body.appendChild(el);
250 const c = getComputedStyle(el).color;
251 el.remove();
252 return c;
253}
254
255// ---- dust (stepDust, lifted from shell.js) ------------------------------------
256let dust = null;
257function initDust() {
258 const canvas = $("dust");
259 if (reduced) return;
260 const dpr = devicePixelRatio || 1, w = innerWidth, h = innerHeight;
261 canvas.width = w * dpr; canvas.height = h * dpr;
262 const g = canvas.getContext("2d");
263 g.scale(dpr, dpr);
264 // Dust must contrast with the floor it sits on: dark specks (muted) on a light
265 // background, LIGHT specks (ink) on a dark one β€” muted-on-dark all but vanishes.
266 // NB: reading a custom property directly returns the RAW `light-dark(…)` token,
267 // which canvas can't parse (fillStyle silently stays black) β€” resolve it through
268 // a real `color` property instead.
269 const darkMode = matchMedia("(prefers-color-scheme: dark)").matches;
270 const ink = resolveColor(darkMode ? "--ink" : "--muted");
271 const accent = resolveColor("--accent");
272 const parts = Array.from({ length: P.motes }, () => ({
273 x: Math.random() * w, y: h - 2 - Math.random() * 2, vx: 0, vy: 0,
274 r: 0.8 + Math.random() * 2.1,
275 c: Math.random() < 0.18 ? accent : ink,
276 a: 0.3 + Math.random() * 0.5,
277 }));
278 dust = { g, w, h, parts, fade: 1 };
279}
280function stepDust(level) {
281 if (!dust) return;
282 const { g, w, h, parts } = dust;
283 g.clearRect(0, 0, w, h);
284 for (const p of parts) {
285 if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * P.kick) {
286 const peak = level > 1 ? 0.5 + Math.random() * 0.4
287 : level * level * (0.12 + Math.random() * 0.22);
288 p.vy = -Math.sqrt(2 * GRAVITY * h * peak);
289 p.vx += (Math.random() - 0.5) * 2.2 * level;
290 }
291 p.vy += GRAVITY; p.y += p.vy; p.x += p.vx; p.vx *= 0.97;
292 if (p.y >= h - 2) { p.y = h - 2; p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; }
293 if (p.x < -4) p.x += w + 8; else if (p.x > w + 4) p.x -= w + 8;
294 g.globalAlpha = p.a * dust.fade;
295 g.fillStyle = p.c;
296 g.beginPath(); g.arc(p.x, p.y, p.r, 0, 7); g.fill();
297 }
298 g.globalAlpha = 1;
299}
300
301// ---- confetti (the "space is ready" payoff) -------------------------------------
302// The rattle cuts out and the whole room celebrates: two cannons from the bottom
303// corners arc pieces right across the viewport, while a sky-wide rain falls for
304// a couple of seconds. Full-spectrum colours (random saturated hues), paper
305// flutter, on the same canvas as the dust.
306const CONFETTI_GRAVITY = 0.18; // floatier than the dust β€” it's paper, not grit
307let confetti = [];
308function confettiPiece(over) {
309 return {
310 delay: 0, t: 0, life: 3800,
311 vx: 0, vy: 0,
312 w: 5 + Math.random() * 6,
313 h: 3 + Math.random() * 5,
314 rot: Math.random() * Math.PI,
315 vr: (Math.random() - 0.5) * 0.35,
316 phase: Math.random() * Math.PI * 2, // per-piece flutter offset
317 c: `hsl(${(Math.random() * 360) | 0} 92% ${52 + Math.random() * 14}%)`,
318 ...over,
319 };
320}
321function spawnConfetti() {
322 if (reduced || !dust) return;
323 const { w, h } = dust;
324 confetti = [];
325 // Corner cannons: launch up and inward so the arcs cross mid-screen.
326 for (let i = 0; i < 75; i++) {
327 const left = i % 2 === 0;
328 confetti.push(confettiPiece({
329 x: left ? -6 : w + 6,
330 y: h * (0.75 + Math.random() * 0.25),
331 vx: (left ? 1 : -1) * (5 + Math.random() * 8),
332 vy: -(11 + Math.random() * 7),
333 delay: Math.random() * 150,
334 }));
335 }
336 // Sky rain: the whole width, staggered over ~1.8s so it keeps coming.
337 for (let i = 0; i < 170; i++) {
338 confetti.push(confettiPiece({
339 x: Math.random() * w,
340 y: -10 - Math.random() * 40,
341 vx: (Math.random() - 0.5) * 2,
342 vy: 1.5 + Math.random() * 2.5,
343 delay: Math.random() * 1800,
344 life: 4600,
345 }));
346 }
347}
348function stepConfetti(dt) {
349 if (!dust || !confetti.length) return;
350 const { g, h } = dust;
351 confetti = confetti.filter((p) => {
352 if (p.delay > 0) { p.delay -= dt; return true; }
353 return (p.t += dt) < p.life && p.y < h + 20;
354 });
355 for (const p of confetti) {
356 if (p.delay > 0) continue;
357 p.vy = Math.min(p.vy + CONFETTI_GRAVITY, 6.5); // terminal velocity: paper drifts
358 p.vx *= 0.99;
359 p.x += p.vx + Math.sin(p.t / 130 + p.phase) * 0.9; // flutter
360 p.y += p.vy;
361 p.rot += p.vr;
362 g.save();
363 g.translate(p.x, p.y);
364 g.rotate(p.rot);
365 // A cheap tumble: the width breathes with the flutter so pieces catch light.
366 const tumble = 0.55 + 0.45 * Math.sin(p.t / 90 + p.phase);
367 g.globalAlpha = Math.min(1, 5 * (1 - p.t / p.life)); // hold, then fade out
368 g.fillStyle = p.c;
369 g.fillRect((-p.w * tumble) / 2, -p.h / 2, p.w * tumble, p.h);
370 g.restore();
371 }
372 g.globalAlpha = 1;
373}
374
375// ---- fader state ---------------------------------------------------------------
376// value: 1..10 while sliding (continuous), 11 only via the snap.
377// phase: slide β†’ snap (the wild 950ms) β†’ build (~5s, still rumbling) β†’ done.
378let value = 1;
379let pressure = 0; // 0..1 β€” builds while pulling past 10, decays otherwise
380let dragging = false;
381let overpull = false; // pointer currently held beyond the 10 stop
382let phase = "slide";
383let phaseT0 = 0;
384let lastT = performance.now();
385let rafId = 0;
386
387const fader = $("fader"), cap = $("cap"), card = $("card"), meter = $("meter");
388
389// Scale: ticks + numbers for 1..10 across RUN_END, a gap, then 11 at the end.
390const nums = [];
391for (let n = 1; n <= 11; n++) {
392 const left = (n === 11 ? 100 : ((n - 1) / 9) * RUN_END * 100) + "%";
393 const tick = document.createElement("span");
394 tick.className = "tick" + (n === 1 || n === 11 ? " major" : "");
395 tick.style.left = left;
396 fader.appendChild(tick);
397 const num = document.createElement("span");
398 num.className = "num" + (n === 11 ? " eleven" : "");
399 num.textContent = n;
400 num.style.left = left;
401 fader.appendChild(num);
402 nums.push(num);
403}
404
405// The LED ladder: one per notch, coloured like a desk meter.
406const leds = [];
407for (let n = 1; n <= 11; n++) {
408 const led = document.createElement("span");
409 led.className = "led";
410 led.dataset.c = n <= 7 ? "g" : n <= 9 ? "a" : "r";
411 meter.appendChild(led);
412 leds.push(led);
413}
414
415function xForValue(v) { // slot-fraction for a 1..10 value
416 return ((v - 1) / 9) * RUN_END;
417}
418function paint() {
419 const past = phase !== "slide"; // snap/build/done: the cap sits AT eleven
420 let fx;
421 if (past) fx = 1;
422 else fx = xForValue(value) + (pressure > 0 ? (pressure * (1 - RUN_END) * 0.35) : 0); // strain toward 11
423 cap.style.left = (fx * 100) + "%";
424 cap.setAttribute("aria-valuenow", past ? 11 : Math.round(value));
425 for (let n = 1; n <= 10; n++) nums[n - 1].classList.toggle("lit", value >= n - 0.01);
426 nums[10].classList.toggle("arming", pressure > 0.05 && !past);
427 nums[10].classList.toggle("hit", past);
428
429 // VU: lit up to the current notch, with a one-LED flicker while the signal's
430 // hot so it reads as a live meter, not a progress bar (steady under reduced
431 // motion). At eleven: all of it, and the last LED strobes.
432 const hot = levelNow();
433 const flicker = !reduced && hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0;
434 const lit = past ? 11 : Math.round(value) + flicker;
435 leds.forEach((led, i) => {
436 const on = i < lit;
437 led.className = "led" + (on ? " on-" + led.dataset.c : "") +
438 ((phase === "snap" || phase === "build") && i === 10 ? " fury" : "");
439 });
440}
441
442// level drives shake + dust, mirroring dialPaint: silent below ~5, trembling by
443// 10, PAST 1.0 at the snap β€” and it STAYS at eleven for the whole build (the
444// volume doesn't come down until the space is ready; then it cuts to silence).
445function levelNow() {
446 if (phase === "snap" || phase === "build") return 1.2;
447 if (phase === "done") return 0;
448 const base = Math.max(0, (value - 5) / 6); // 0 at 5 … ~0.83 at 10
449 return base + pressure * 0.25; // the hold adds strain
450}
451
452function tick(t) {
453 const dt = Math.min(50, t - lastT); lastT = t;
454
455 // Pressure: build while pulled past the stop, decay when released/backed off.
456 if (phase === "slide") {
457 if (overpull && value >= 9.99) pressure = Math.min(1, pressure + dt / P.hold);
458 else pressure = Math.max(0, pressure - dt / 300);
459 if (pressure >= 1) { phase = "snap"; phaseT0 = t; value = 10; pressure = 0; } // the SNAP: no travel, no ease
460 }
461
462 const level = levelNow();
463 stepDust(level);
464 stepConfetti(dt);
465
466 // Loudness shake on the whole card: CRANKED at the snap and held there
467 // through the whole build, cutting out the moment it's ready.
468 if (!reduced && level > 0) {
469 const mult = phase === "snap" || phase === "build" ? P.crank : 1;
470 const a = P.shake * level * mult;
471 card.style.transform =
472 `translate(${((Math.random() - .5) * 2 * a).toFixed(2)}px, ${((Math.random() - .5) * 2 * a).toFixed(2)}px)` +
473 ` rotate(${((Math.random() - .5) * 1.6 * level * mult).toFixed(2)}deg)`;
474 } else {
475 card.style.transform = "";
476 }
477
478 // Choreography: snap wild for 950ms (clickDialToEleven timing) β†’ the building
479 // state, still rumbling with the dust flying (in production this covers the
480 // POST /api/spaces round-trip, ~5s β€” it returns once the space is confirmed
481 // reachable) β†’ done: the rattle cuts out and the confetti flies. The dust
482 // takes a quick bow at the handover.
483 if (phase === "snap" && t - phaseT0 > 950) {
484 phase = "build"; phaseT0 = t;
485 $("pitch").style.display = "none";
486 $("building").style.display = "block";
487 } else if (phase === "build" && t - phaseT0 > P.build) {
488 phase = "done"; phaseT0 = t;
489 $("building").style.display = "none";
490 $("done").style.display = "block";
491 spawnConfetti();
492 }
493 if (phase === "done" && dust) dust.fade = Math.max(0, 1 - (t - phaseT0) / 600);
494
495 paint();
496 // Park the loop once the show is over (reset restarts it).
497 if (phase === "done" && !confetti.length && (!dust || dust.fade === 0)) {
498 card.style.transform = "";
499 if (dust) stepDust(0); // one clean final frame (also clears settled motes)
500 return;
501 }
502 rafId = requestAnimationFrame(tick);
503}
504
505// ---- dragging -------------------------------------------------------------------
506function valueFromPointer(clientX) {
507 const r = fader.getBoundingClientRect();
508 const f = (clientX - r.left) / r.width;
509 return 1 + Math.min(1, Math.max(0, f / RUN_END)) * 9; // clamped to 1..10
510}
511function onDown(e) {
512 if (phase !== "slide") return;
513 dragging = true;
514 fader.classList.add("dragging");
515 cap.setPointerCapture?.(e.pointerId);
516 onMove(e);
517}
518function onMove(e) {
519 if (!dragging || phase !== "slide") return;
520 const r = fader.getBoundingClientRect();
521 value = valueFromPointer(e.clientX);
522 // The overpull: at the 10 stop AND still pulling meaningfully past it.
523 overpull = value >= 9.99 && e.clientX > r.left + r.width * RUN_END + 14;
524}
525function onUp() {
526 dragging = false;
527 overpull = false;
528 fader.classList.remove("dragging");
529}
530cap.addEventListener("pointerdown", onDown);
531fader.addEventListener("pointerdown", onDown);
532addEventListener("pointermove", onMove);
533addEventListener("pointerup", onUp);
534addEventListener("pointercancel", onUp);
535
536// Keyboard fallback: arrows walk the notches; at 10, holding β†’ for the hold
537// time arms eleven (End does not β€” eleven must be earned).
538cap.addEventListener("keydown", (e) => {
539 if (phase !== "slide") return;
540 if (e.key === "ArrowRight") { value = Math.min(10, Math.round(value) + 1); overpull = value >= 10; }
541 else if (e.key === "ArrowLeft") { value = Math.max(1, Math.round(value) - 1); overpull = false; }
542});
543cap.addEventListener("keyup", () => { overpull = false; });
544
545// ---- reset + panel ---------------------------------------------------------------
546function resetLever() {
547 cancelAnimationFrame(rafId); // never two loops β€” reset can land mid-run
548 phase = "slide"; value = 1; pressure = 0; overpull = false;
549 confetti = [];
550 if (dust) dust.fade = 1;
551 $("pitch").style.display = "";
552 $("building").style.display = "none";
553 $("done").style.display = "none";
554 lastT = performance.now();
555 rafId = requestAnimationFrame(tick);
556}
557$("reset").addEventListener("click", resetLever);
558$("p-reset").addEventListener("click", resetLever);
559
560function bind(id, key, fmt) {
561 $("p-" + id).addEventListener("input", (e) => {
562 P[key] = +e.target.value;
563 $("v-" + id).textContent = fmt ? fmt(P[key]) : P[key];
564 if (key === "motes") initDust(); // re-seed the cloud at the new density
565 });
566}
567bind("hold", "hold"); bind("shake", "shake"); bind("crank", "crank");
568bind("kick", "kick", (v) => v.toFixed(2)); bind("motes", "motes"); bind("build", "build");
569
570addEventListener("resize", initDust);
571matchMedia("(prefers-color-scheme: dark)").addEventListener("change", initDust);
572// `reduced` is read once at load; if the OS setting flips mid-session, reload β€”
573// the page is stateless, and a fresh boot honours the new preference everywhere.
574matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => location.reload());
575initDust();
576rafId = requestAnimationFrame(tick);
577</script>
578</body>
579</html>
580