| 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) ----------------------------------------- |
| 230 | const 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 | }; |
| 238 | const GRAVITY = 0.62; // shell.js DUST_GRAVITY |
| 239 | const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap |
| 240 | const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 241 | const $ = (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. |
| 246 | function 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) ------------------------------------ |
| 256 | let dust = null; |
| 257 | function 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 | } |
| 280 | function 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. |
| 306 | const CONFETTI_GRAVITY = 0.18; // floatier than the dust β it's paper, not grit |
| 307 | let confetti = []; |
| 308 | function 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 | } |
| 321 | function 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 | } |
| 348 | function 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. |
| 378 | let value = 1; |
| 379 | let pressure = 0; // 0..1 β builds while pulling past 10, decays otherwise |
| 380 | let dragging = false; |
| 381 | let overpull = false; // pointer currently held beyond the 10 stop |
| 382 | let phase = "slide"; |
| 383 | let phaseT0 = 0; |
| 384 | let lastT = performance.now(); |
| 385 | let rafId = 0; |
| 386 | |
| 387 | const 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. |
| 390 | const nums = []; |
| 391 | for (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. |
| 406 | const leds = []; |
| 407 | for (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 | |
| 415 | function xForValue(v) { // slot-fraction for a 1..10 value |
| 416 | return ((v - 1) / 9) * RUN_END; |
| 417 | } |
| 418 | function 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). |
| 445 | function 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 | |
| 452 | function 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 ------------------------------------------------------------------- |
| 506 | function 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 | } |
| 511 | function onDown(e) { |
| 512 | if (phase !== "slide") return; |
| 513 | dragging = true; |
| 514 | fader.classList.add("dragging"); |
| 515 | cap.setPointerCapture?.(e.pointerId); |
| 516 | onMove(e); |
| 517 | } |
| 518 | function 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 | } |
| 525 | function onUp() { |
| 526 | dragging = false; |
| 527 | overpull = false; |
| 528 | fader.classList.remove("dragging"); |
| 529 | } |
| 530 | cap.addEventListener("pointerdown", onDown); |
| 531 | fader.addEventListener("pointerdown", onDown); |
| 532 | addEventListener("pointermove", onMove); |
| 533 | addEventListener("pointerup", onUp); |
| 534 | addEventListener("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). |
| 538 | cap.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 | }); |
| 543 | cap.addEventListener("keyup", () => { overpull = false; }); |
| 544 | |
| 545 | // ---- reset + panel --------------------------------------------------------------- |
| 546 | function 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 | |
| 560 | function 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 | } |
| 567 | bind("hold", "hold"); bind("shake", "shake"); bind("crank", "crank"); |
| 568 | bind("kick", "kick", (v) => v.toFixed(2)); bind("motes", "motes"); bind("build", "build"); |
| 569 | |
| 570 | addEventListener("resize", initDust); |
| 571 | matchMedia("(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. |
| 574 | matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => location.reload()); |
| 575 | initDust(); |
| 576 | rafId = requestAnimationFrame(tick); |
| 577 | </script> |
| 578 | </body> |
| 579 | </html> |
| 580 | |