home: ship the goes-to-eleven fader as the real create-a-space flow (#369)
The create screen's button becomes the instrument iterated in prototypes/space-lever.html: a mixing-desk fader you slide 1→10 (VU LED ladder, card shake, stage dust bouncing along the viewport floor); hold the pull past 10 for a beat and it SNAPS to eleven — which fires the real POST /api/spaces. The build phase holds the volume AT eleven for however long the create actually takes (the API returns once the space is confirmed reachable, ~5s), showing "Hold on… / Building your space"; success cuts to silence + full-screen rainbow confetti over the real confirmation (setup link + Done). Errors (quota, capacity, network) bring the volume down, reset the fader, restore the back link, and show the member-facing message inline — slide again to retry. Mechanics: new home/web/static/fader.js — framework-free; pure math (faderLevel/faderValue) exported and unit-tested in Node (home/test/fader.test.mjs, wired into npm test); mountFader owns all DOM. Physics shared with the boot amp knob (shell.js) and the prototype. The back link hides while a create is in flight (it can't be cancelled). Full prefers-reduced-motion coverage: no dust/confetti/shake/strobe/ flicker — states change instantly; the flow is identical. Text selection suppressed on the instrument only (the confirmation stays copyable). Dust + confetti draw on a full-viewport stage canvas behind the card, only while the create screen is live.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+580
−52
home/test/fader.test.mjs+43 −0home/web/pages/index.html+108 −5home/web/static/fader.js+371 −0home/web/static/home.js+57 −46package.json+1 −1
diff --git a/home/test/fader.test.mjs b/home/test/fader.test.mjs| new file mode 100644 |
| index 0000000..618b78a |
| --- /dev/null |
| +++ b/home/test/fader.test.mjs |
| @@ -0,0 +1,43 @@ |
| +// Tests for the goes-to-eleven fader's pure math (no DOM — mountFader owns all |
| +// document references, so the module imports cleanly in Node). |
| +// node home/test/fader.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { faderLevel, faderValue } from "../web/static/fader.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +await test("faderValue maps the slot run to 1..10 and clamps beyond it", () => { |
| + assert.equal(faderValue(0), 1); |
| + assert.equal(faderValue(0.86), 10, "the end of the run (RUN_END) is 10"); |
| + assert.equal(faderValue(1), 10, "past the run still clamps to 10 — eleven is not reachable by position"); |
| + assert.equal(faderValue(-0.5), 1, "before the slot clamps to 1"); |
| + assert.ok(faderValue(0.43) > 5 && faderValue(0.43) < 6.6, "mid-run lands mid-scale"); |
| +}); |
| + |
| +await test("faderLevel is silent below 5, hot by 10, and pressure adds strain", () => { |
| + assert.equal(faderLevel("slide", 1, 0), 0); |
| + assert.equal(faderLevel("slide", 5, 0), 0); |
| + assert.ok(Math.abs(faderLevel("slide", 10, 0) - 5 / 6) < 1e-9); |
| + assert.ok(faderLevel("slide", 10, 0.8) > faderLevel("slide", 10, 0), "the hold adds strain"); |
| +}); |
| + |
| +await test("faderLevel holds PAST 1.0 at the snap and through the whole build", () => { |
| + assert.equal(faderLevel("snap", 10, 0), 1.2); |
| + assert.equal(faderLevel("build", 10, 0), 1.2, "the volume stays AT eleven while building"); |
| +}); |
| + |
| +await test("faderLevel cuts to zero at done (silence before the confetti)", () => { |
| + assert.equal(faderLevel("done", 10, 0), 0); |
| +}); |
| + |
| +console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED"); |
| +process.exit(failures ? 1 : 0); |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index c291dfe..cb106e2 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -72,14 +72,99 @@ |
| } |
| .wide-quiet:hover { filter: brightness(0.98); } |
| .wide-quiet:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| - /* Create screen: centered and spare — the "one click" promise is the design. */ |
| + /* Create screen: the goes-to-eleven fader (fader.js). Centered and spare — |
| + one line, one instrument. Feel iterated in prototypes/space-lever.html. */ |
| #create { text-align: center; } |
| .back { display: block; width: fit-content; text-align: left; margin: 0 0 6px; font-size: .9rem; } |
| .create-title { |
| font-family: var(--font-display); font-size: 23px; font-weight: 700; |
| - line-height: 1.2; letter-spacing: -0.015em; margin: 20px 0 22px; |
| + line-height: 1.2; letter-spacing: -0.015em; margin: 20px 0 4px; |
| + } |
| + .create-sub { color: var(--muted); font-size: 14px; margin: 0 0 24px; } |
| + /* The stage: dust + confetti behind the card, full-viewport. */ |
| + #stage-dust { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; } |
| + main.card { position: relative; z-index: 1; will-change: transform; } |
| + /* VU colours: desk vernacular (green/amber/red) on purpose — this is a meter. */ |
| + :root { --led-g: light-dark(#31a24c, #3fd063); --led-a: light-dark(#e8a23a, #f0b45a); |
| + --led-r: light-dark(#e5484d, #ff5d63); --slot: light-dark(#d3d7e2, #0e1014); } |
| + /* The instrument is not a document: a drag that wanders off the fader must |
| + never start selecting the headline mid-slide. */ |
| + #create-pitch { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } |
| + .strip { padding: 4px 2px 10px; } |
| + .meter { display: flex; gap: 4px; margin: 0 2px 16px; } |
| + .led { |
| + flex: 1; height: 9px; border-radius: 2px; |
| + background: color-mix(in srgb, var(--ink) 10%, transparent); |
| + box-shadow: inset 0 1px 1px rgba(0,0,0,.18); |
| + transition: background .06s; |
| + } |
| + .led.on-g { background: var(--led-g); box-shadow: 0 0 6px color-mix(in srgb, var(--led-g) 60%, transparent); } |
| + .led.on-a { background: var(--led-a); box-shadow: 0 0 6px color-mix(in srgb, var(--led-a) 60%, transparent); } |
| + .led.on-r { background: var(--led-r); box-shadow: 0 0 8px color-mix(in srgb, var(--led-r) 70%, transparent); } |
| + @keyframes led-fury { 0%, 100% { opacity: 1; } 50% { opacity: .25; } } |
| + .led.fury { animation: led-fury .14s linear infinite; } |
| + .fader { position: relative; height: 46px; margin: 0 2px; touch-action: none; cursor: grab; } |
| + .fader.dragging { cursor: grabbing; } |
| + .slot { |
| + position: absolute; left: 0; right: 0; top: 50%; height: 6px; translate: 0 -50%; |
| + border-radius: 3px; background: var(--slot); |
| + box-shadow: inset 0 1px 3px rgba(0,0,0,.35), inset 0 -1px 0 rgba(255,255,255,.25); |
| + } |
| + .tick { |
| + position: absolute; top: 4px; width: 1.5px; height: 7px; translate: -50% 0; |
| + background: color-mix(in srgb, var(--muted) 55%, transparent); border-radius: 1px; |
| + } |
| + .tick.major { height: 10px; top: 1px; } |
| + .num { |
| + position: absolute; top: 100%; translate: -50% 0; padding-top: 2px; |
| + font-size: 11px; font-weight: 600; color: var(--muted); |
| + font-variant-numeric: tabular-nums; |
| + } |
| + .num.lit { color: var(--ink); } |
| + .num.eleven { font-weight: 800; transition: color .12s, transform .12s; } |
| + .num.eleven.arming { color: var(--led-r); } |
| + .num.eleven.hit { color: var(--led-r); transform: scale(1.4); } |
| + .cap { |
| + position: absolute; top: 50%; translate: -50% -50%; |
| + width: 30px; height: 44px; border-radius: 6px; |
| + background: linear-gradient(180deg, |
| + light-dark(#ffffff, #343946) 0%, |
| + light-dark(#e7eaf1, #262a33) 48%, |
| + light-dark(#d9dde8, #1e222a) 52%, |
| + light-dark(#eef0f6, #2b3039) 100%); |
| + border: 1px solid light-dark(#c4c9d6, #0c0e12); |
| + box-shadow: 0 3px 8px var(--shadow), inset 0 1px 0 rgba(255,255,255,.55); |
| + cursor: grab; |
| + } |
| + .cap::before, .cap::after { content: ""; position: absolute; left: 4px; right: 4px; height: 1px; |
| + background: light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); } |
| + .cap::before { top: 9px; box-shadow: 0 4px 0 light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); } |
| + .cap::after { bottom: 9px; box-shadow: 0 -4px 0 light-dark(rgba(0,0,0,.10), rgba(255,255,255,.08)); } |
| + .stripe { |
| + position: absolute; top: 6px; bottom: 6px; left: 50%; width: 3px; translate: -50% 0; |
| + border-radius: 2px; background: var(--accent); z-index: 1; |
| + } |
| + .fader.dragging .cap { cursor: grabbing; } |
| + .cap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| + /* The wait: still AT eleven around it (the fader keeps the card rattling). */ |
| + #create-building { padding: 18px 0 6px; } |
| + .build-spin { |
| + width: 34px; height: 34px; margin: 4px auto 14px; border-radius: 50%; |
| + border: 3px solid color-mix(in srgb, var(--ink) 12%, transparent); |
| + border-top-color: var(--accent); |
| + animation: spin .7s linear infinite; |
| + } |
| + @keyframes spin { to { transform: rotate(360deg); } } |
| + #create-building h3 { font-size: 17px; font-weight: 700; margin: 0 0 4px; } |
| + #create-building p { color: var(--muted); font-size: 14px; margin: 0; } |
| + /* Reduced motion: no strobe, no spinner spin, no transitions — states still |
| + change, instantly. (Dust, confetti, shake, and VU flicker are skipped in |
| + fader.js under the same query.) */ |
| + @media (prefers-reduced-motion: reduce) { |
| + .led.fury { animation: none; } |
| + .led, .num.eleven { transition: none; } |
| + .build-spin { animation: none; opacity: .5; } |
| } |
| - #create #new-space { width: 100%; } |
| /* Section headings: "Starred" and "More" — only shown once some are starred. */ |
| .spaces-head { |
| font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; |
| @@ -145,6 +230,9 @@ |
| </style> |
| </head> |
| <body class="landing"> |
| +<!-- Stage for the create-fader's dust + confetti (fader.js) — full-viewport, |
| + behind the card, drawn only while the create screen is live. --> |
| +<canvas id="stage-dust" aria-hidden="true"></canvas> |
| <main class="card"> |
| <div class="brand"> |
| <img class="brand-mark" src="/static/icon-512.png" width="64" height="64" alt="Eleven" /> |
| @@ -193,10 +281,25 @@ |
| <div id="create" class="hidden screen"> |
| <button id="create-back" class="back linkish" type="button">‹ Spaces</button> |
| <div id="create-pitch"> |
| - <h2 class="create-title">Create a private space in one click.</h2> |
| - <button id="new-space" type="button">Create space now</button> |
| + <h2 class="create-title">Slide to create</h2> |
| + <p class="create-sub">Your new space will be created immediately.</p> |
| + <!-- The goes-to-eleven fader (fader.js): slide 1→10, hold the pull for a |
| + beat and it snaps to eleven — which fires the real create. --> |
| + <div class="strip"> |
| + <div class="meter" id="fader-meter"></div> |
| + <div class="fader" id="fader"> |
| + <div class="slot"></div> |
| + <div class="cap" role="slider" aria-label="Slide to create" |
| + aria-valuemin="1" aria-valuemax="11" aria-valuenow="1" tabindex="0"><div class="stripe"></div></div> |
| + </div> |
| + </div> |
| <p class="status" id="new-space-status"></p> |
| </div> |
| + <div id="create-building" class="hidden"> |
| + <div class="build-spin" aria-hidden="true"></div> |
| + <h3>Hold on…</h3> |
| + <p>Building your space</p> |
| + </div> |
| <div id="new-space-result" class="hidden"> |
| <div id="new-space-body"></div> |
| <button id="create-done" type="button" class="wide-quiet">Done</button> |
diff --git a/home/web/static/fader.js b/home/web/static/fader.js| new file mode 100644 |
| index 0000000..2ac1bef |
| --- /dev/null |
| +++ b/home/web/static/fader.js |
| @@ -0,0 +1,371 @@ |
| +// 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 }; |
| +} |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index f72f089..636b8a1 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -22,6 +22,7 @@ import { |
| import { |
| partitionServers, toggleStar, moveInGroup, orderServers, filterServers, |
| } from "/static/serverlist.js"; |
| +import { mountFader } from "/static/fader.js"; |
| // ---- session state ---------------------------------------------------------- |
| // Session-only on purpose (the design doc's web default): closing the tab |
| @@ -434,49 +435,33 @@ async function persistVault() { |
| // ---- create a new space ------------------------------------------------------- |
| -// createSpace asks this home server to provision a brand-new Eleven space for |
| -// the signed-in user (POST /api/spaces, link-token auth), then folds it into |
| -// the vault so sign-in always finds the way back. The vault must be unlocked: |
| -// that's how we hold the DEK to save the new entry (and the link token to ask). |
| -async function createSpace() { |
| - if (!vault) { |
| - spaceStatus("Sign in first — your new space is saved into your account.", "error"); |
| - return; |
| +// requestSpace is the real create call, fired by the fader the moment it snaps |
| +// to eleven: POST /api/spaces (link-token auth) — it returns only once the |
| +// server has confirmed the space is live and reachable, so the fader's build |
| +// phase IS the wait — then fold the new space into the vault so sign-in always |
| +// finds the way back. Throws a member-facing Error for the fader's error path. |
| +async function requestSpace() { |
| + if (!vault) throw new Error("Sign in first — your new space is saved into your account."); |
| + const res = await fetch("/api/spaces", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token }, |
| + }); |
| + const body = await res.json().catch(() => ({})); |
| + if (!res.ok) { |
| + // Structured errors carry a member-facing message (quota, at-capacity). |
| + throw new Error(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`); |
| } |
| - const btn = $("new-space"); |
| - btn.disabled = true; // one space per click — a double-tap would spend quota twice |
| - // The request only returns once the server has confirmed the space is live and |
| - // reachable (see confirmPublicReady), so the link we reveal works on first tap. |
| - spaceStatus("Building your space and getting it ready…", "busy"); |
| + const url = body.url; |
| + const host = new URL(url).host; |
| + vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| + vault.obj.servers.push({ url, name: host }); |
| + let saved = true; |
| try { |
| - const res = await fetch("/api/spaces", { |
| - method: "POST", |
| - headers: { Authorization: "Bearer " + state.token }, |
| - }); |
| - const body = await res.json().catch(() => ({})); |
| - if (!res.ok) { |
| - // Structured errors carry a member-facing message (quota, at-capacity). |
| - spaceStatus(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`, "error"); |
| - return; |
| - } |
| - // Save it into the list (last-writer-wins per url). It'll appear once you |
| - // return; for now the create screen shows the single-serve confirmation. |
| - const url = body.url; |
| - const host = new URL(url).host; |
| - vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| - vault.obj.servers.push({ url, name: host }); |
| - let saved = true; |
| - try { |
| - await saveVault(); |
| - } catch { |
| - saved = false; // the space exists even if the save raced; still confirm it |
| - } |
| - spaceStatus(""); |
| - $("create-pitch").classList.add("hidden"); |
| - showNewSpaceResult(host, body.setup_link, saved); |
| - } finally { |
| - btn.disabled = false; |
| + await saveVault(); |
| + } catch { |
| + saved = false; // the space exists even if the save raced; still confirm it |
| } |
| + return { host, setupLink: body.setup_link, saved }; |
| } |
| // A checkmark for the single-serve confirmation (static, in-code SVG). |
| @@ -533,17 +518,46 @@ function showNewSpaceResult(host, setupLink, saved) { |
| // ---- create-screen navigation ------------------------------------------------- |
| +// The fader mounts once (it builds its scale + meter into the DOM) and is |
| +// reset/started per visit; stop() parks its loop and clears the stage canvas. |
| +let fader = null; |
| +function ensureFader() { |
| + if (fader) return fader; |
| + fader = mountFader({ |
| + fader: $("fader"), |
| + meter: $("fader-meter"), |
| + shakeEl: document.querySelector("main.card"), |
| + canvas: $("stage-dust"), |
| + pitch: $("create-pitch"), |
| + building: $("create-building"), |
| + // The back link hides the moment the snap fires — the create is in flight |
| + // and can't be cancelled; error/ready each restore a way out. |
| + onEleven: () => { |
| + $("create-back").classList.add("hidden"); |
| + return requestSpace(); |
| + }, |
| + onReady: (r) => showNewSpaceResult(r.host, r.setupLink, r.saved), |
| + onError: (err) => { |
| + $("create-back").classList.remove("hidden"); |
| + spaceStatus((err && err.message) || String(err), "error"); |
| + }, |
| + }); |
| + return fader; |
| +} |
| + |
| function openCreate() { |
| - $("create-pitch").classList.remove("hidden"); |
| $("new-space-result").classList.add("hidden"); |
| $("create-back").classList.remove("hidden"); |
| - $("new-space").disabled = false; |
| spaceStatus(""); |
| $("vault").classList.add("hidden"); |
| $("create").classList.remove("hidden"); |
| + const f = ensureFader(); |
| + f.reset(); |
| + f.start(); |
| } |
| function closeCreate() { |
| + fader?.stop(); |
| $("create").classList.add("hidden"); |
| showVault(); // reveals the list again, now including any newly created space |
| } |
| @@ -584,10 +598,7 @@ $("signout").addEventListener("click", signOut); |
| $("open-create").addEventListener("click", openCreate); |
| $("create-back").addEventListener("click", closeCreate); |
| $("create-done").addEventListener("click", closeCreate); |
| -$("new-space").addEventListener("click", () => createSpace().catch((err) => { |
| - if (err && err.name === "NotAllowedError") spaceStatus("Passkey prompt was dismissed.", ""); |
| - else spaceStatus((err && err.message) || String(err), "error"); |
| -})); |
| +// (Creating a space is the fader now — mounted in openCreate; see fader.js.) |
| // Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input |
| // lives outside #spaces, so re-rendering the lists never steals its focus. |
| $("space-search").addEventListener("input", () => { if (vault) showVault(); }); |
diff --git a/package.json b/package.json| index cf4badf..a62b037 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |