eleven / messenger Public

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>
Paul Campbell 6cb40debdadd6875034d7a6eacba53ae696012de parent b862f21
5 files changed, +580 −52
  • home/test/fader.test.mjs +43 −0
  • home/web/pages/index.html +108 −5
  • home/web/static/fader.js +371 −0
  • home/web/static/home.js +57 −46
  • package.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"
}
}