| new file mode 100644 |
| index 0000000..cc40d5c |
| --- /dev/null |
| +++ b/prototypes/space-lever.html |
| @@ -0,0 +1,369 @@ |
| +<!doctype html> |
| +<!-- 🤖 Prototype: the "goes to eleven" create-a-space lever. |
| + |
| + A local practice page — open it straight from disk, no server, nothing is |
| + provisioned. The plan: iterate the FEEL here (drag physics, shake, dust, |
| + the 10→11 snap), then port the blessed version into home's create screen |
| + (home/web/pages/index.html + home.js), wired so that reaching ELEVEN is |
| + what fires POST /api/spaces. |
| + |
| + The physics are lifted from the product's boot amp knob (web/static/ |
| + shell.js: stepDust/dialPaint/clickDialToEleven) so the lever speaks the |
| + same language: level² hops, gravity 0.62, kick odds 0.02+level*0.2, |
| + CRANKED=2.3 at eleven. Tweak everything live in the panel (bottom right). |
| +--> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>Space lever — prototype</title> |
| +<style> |
| + /* Tokens copied from home.css (classic-neutral, indigo accent). */ |
| + :root { |
| + color-scheme: light dark; |
| + --bg: light-dark(#f6f7fb, #15171c); |
| + --panel: light-dark(#ffffff, #1d2026); |
| + --ink: light-dark(#1c2333, #e7e9f0); |
| + --muted: light-dark(#5a6170, #9aa1b3); |
| + --border: light-dark(#e7e9f0, rgba(255,255,255,.10)); |
| + --accent: light-dark(#5666f0, #7d8bff); |
| + --accent-ink: light-dark(#ffffff, #0a0e2a); |
| + --quiet: light-dark(#eef0f6, #2a2e38); |
| + --shadow: light-dark(rgba(20,30,80,.10), rgba(0,0,0,.55)); |
| + --ok: light-dark(#237a41, #58b97e); |
| + } |
| + * { box-sizing: border-box; } |
| + body { |
| + margin: 0; min-height: 100svh; display: grid; place-items: center; |
| + font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| + background: var(--bg); color: var(--ink); |
| + overflow: hidden; /* dust flies the whole viewport; no scrollbars */ |
| + } |
| + canvas#dust { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; } |
| + |
| + .card { |
| + position: relative; z-index: 1; |
| + background: var(--panel); border: 1px solid var(--border); border-radius: 24px; |
| + padding: 28px; width: min(420px, calc(100vw - 32px)); |
| + box-shadow: 0 12px 40px var(--shadow); text-align: center; |
| + will-change: transform; |
| + } |
| + h1 { font-size: 23px; font-weight: 700; line-height: 1.2; letter-spacing: -0.015em; margin: 4px 0 6px; } |
| + p.sub { color: var(--muted); font-size: 14px; margin: 0 0 26px; } |
| + |
| + /* ---- the lever ---------------------------------------------------------- */ |
| + .lever { position: relative; margin: 0 6px 6px; touch-action: none; } |
| + .rail { |
| + position: relative; height: 14px; border-radius: 999px; |
| + background: var(--quiet); cursor: grab; |
| + } |
| + .fill { |
| + position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 999px; |
| + background: var(--accent); |
| + } |
| + /* Notches: ticks on the rail; numbers beneath. 11 sits past a deliberate gap. */ |
| + .notch { position: absolute; top: 100%; translate: -50% 0; padding-top: 9px; } |
| + .notch::before { |
| + content: ""; position: absolute; top: 2px; left: 50%; translate: -50% 0; |
| + width: 2px; height: 5px; border-radius: 1px; background: var(--border); |
| + } |
| + .notch { font-size: 11px; font-weight: 600; color: var(--muted); } |
| + .notch.lit { color: var(--ink); } |
| + .notch.eleven { font-weight: 800; color: var(--muted); transition: color .12s, transform .12s; } |
| + .notch.eleven.arming { color: var(--accent); } |
| + .notch.eleven.hit { color: var(--accent); transform: scale(1.35); } |
| + .handle { |
| + position: absolute; top: 50%; translate: -50% -50%; |
| + width: 34px; height: 34px; border-radius: 50%; |
| + background: var(--panel); border: 2px solid var(--accent); |
| + box-shadow: 0 2px 10px var(--shadow); |
| + cursor: grab; touch-action: none; |
| + display: grid; place-items: center; |
| + } |
| + .handle::after { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); } |
| + .lever.dragging .rail, .lever.dragging .handle { cursor: grabbing; } |
| + .hint { margin: 26px 0 0; font-size: 13px; color: var(--muted); } |
| + |
| + /* success pane (single-serve confirmation mock) */ |
| + #done { display: none; } |
| + .ok-check { |
| + width: 46px; height: 46px; margin: 6px auto 12px; border-radius: 50%; |
| + display: grid; place-items: center; background: var(--ok); color: #fff; |
| + } |
| + .ok-check svg { width: 22px; height: 22px; } |
| + #done h2 { font-size: 17px; font-weight: 700; margin: 0 0 4px; } |
| + #done p { color: var(--muted); font-size: 14px; margin: 0 0 16px; } |
| + button.quiet { |
| + width: 100%; font: inherit; font-weight: 600; cursor: pointer; |
| + background: var(--quiet); color: var(--ink); border: none; border-radius: 999px; padding: 11px 18px; |
| + } |
| + |
| + /* ---- tweakables panel (dev only) ---------------------------------------- */ |
| + #panel { |
| + position: fixed; right: 12px; bottom: 12px; z-index: 5; |
| + background: var(--panel); border: 1px solid var(--border); border-radius: 12px; |
| + padding: 10px 12px; font-size: 12px; color: var(--muted); |
| + box-shadow: 0 6px 20px var(--shadow); width: 230px; |
| + } |
| + #panel label { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin: 4px 0; } |
| + #panel input[type=range] { width: 110px; accent-color: var(--accent); } |
| + #panel .v { min-width: 34px; text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; } |
| + #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; } |
| +</style> |
| +</head> |
| +<body> |
| +<canvas id="dust"></canvas> |
| + |
| +<main class="card" id="card"> |
| + <div id="pitch"> |
| + <h1>Create a private space in one click.</h1> |
| + <p class="sub">Well… one slide.</p> |
| + <div class="lever" id="lever"> |
| + <div class="rail" id="rail"><div class="fill" id="fill"></div></div> |
| + <div class="handle" id="handle" role="slider" aria-label="Slide to eleven" |
| + aria-valuemin="1" aria-valuemax="11" aria-valuenow="1" tabindex="0"></div> |
| + </div> |
| + <p class="hint" id="hint">Slide it to eleven</p> |
| + </div> |
| + |
| + <div id="done"> |
| + <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> |
| + <h2>Your space is ready</h2> |
| + <p>(Prototype — nothing was provisioned. This is where the confirmation pane goes.)</p> |
| + <button class="quiet" id="reset">Slide it again</button> |
| + </div> |
| +</main> |
| + |
| +<div id="panel"> |
| + <strong style="color:var(--ink)">Feel</strong> |
| + <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> |
| + <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> |
| + <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> |
| + <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> |
| + <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> |
| + <button id="p-reset">Reset lever</button> |
| +</div> |
| + |
| +<script> |
| +// ---- tweakables (bound to the panel) ----------------------------------------- |
| +const P = { |
| + hold: 900, // ms of continued pull at 10 before the snap to 11 |
| + shake: 1.7, // base shake amplitude (px) at full level — shell.js used 1.7 |
| + crank: 2.3, // shake multiplier AT eleven — shell.js CRANKED |
| + kick: 0.2, // dust kick odds scale — shell.js used level*0.2 |
| + motes: 90, // particle count |
| +}; |
| +const GRAVITY = 0.62; // shell.js DUST_GRAVITY |
| +const RUN_END = 0.86; // 1..10 live in the first 86% of the rail; 11 past the gap |
| +const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| +const $ = (id) => document.getElementById(id); |
| + |
| +// ---- dust (stepDust, lifted from shell.js) ------------------------------------ |
| +let dust = null; |
| +function initDust() { |
| + const canvas = $("dust"); |
| + if (reduced) return; |
| + const dpr = devicePixelRatio || 1, w = innerWidth, h = innerHeight; |
| + canvas.width = w * dpr; canvas.height = h * dpr; |
| + const g = canvas.getContext("2d"); |
| + g.scale(dpr, dpr); |
| + const css = getComputedStyle(document.documentElement); |
| + const ink = css.getPropertyValue("--muted").trim(); |
| + const accent = css.getPropertyValue("--accent").trim(); |
| + const parts = Array.from({ length: P.motes }, () => ({ |
| + 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 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 * P.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; |
| +} |
| + |
| +// ---- lever state --------------------------------------------------------------- |
| +// value: 1..10 while sliding (continuous), 11 only via the snap. |
| +let value = 1; |
| +let pressure = 0; // 0..1 — builds while pulling past 10, decays otherwise |
| +let dragging = false; |
| +let overpull = false; // pointer currently held beyond the 10 stop |
| +let snapped = false; // reached ELEVEN (locks the lever, plays the finish) |
| +let snapT0 = 0; |
| +let lastT = performance.now(); |
| + |
| +const lever = $("lever"), rail = $("rail"), fill = $("fill"), handle = $("handle"), card = $("card"); |
| + |
| +// Notch layout: 1..10 across RUN_END, a gap, then 11 at the very end. |
| +const notches = []; |
| +for (let n = 1; n <= 11; n++) { |
| + const el = document.createElement("span"); |
| + el.className = "notch" + (n === 11 ? " eleven" : ""); |
| + el.textContent = n; |
| + el.style.left = (n === 11 ? 100 : ((n - 1) / 9) * RUN_END * 100) + "%"; |
| + rail.parentElement.appendChild(el); |
| + notches.push(el); |
| +} |
| + |
| +function xForValue(v) { // rail-fraction for a 1..10 value |
| + return ((v - 1) / 9) * RUN_END; |
| +} |
| +function paint() { |
| + const w = rail.clientWidth; |
| + let fx; |
| + if (snapped) fx = 1; |
| + else fx = xForValue(value) + (pressure > 0 ? (pressure * (1 - RUN_END) * 0.35) : 0); // strain toward 11 |
| + fill.style.width = (fx * 100) + "%"; |
| + handle.style.left = (fx * 100) + "%"; |
| + handle.setAttribute("aria-valuenow", snapped ? 11 : Math.round(value)); |
| + for (let n = 1; n <= 10; n++) notches[n - 1].classList.toggle("lit", value >= n - 0.01); |
| + notches[10].classList.toggle("arming", pressure > 0.05 && !snapped); |
| + notches[10].classList.toggle("hit", snapped); |
| +} |
| + |
| +// level drives shake + dust, mirroring dialPaint: silent below ~5, trembling by |
| +// 10, and PAST 1.0 only at eleven (which sends the dust to the top). |
| +function levelNow() { |
| + if (snapped) return 1.2; |
| + const base = Math.max(0, (value - 5) / 6); // 0 at 5 … ~0.83 at 10 |
| + return base + pressure * 0.25; // the hold adds strain |
| +} |
| + |
| +function tick(t) { |
| + const dt = Math.min(50, t - lastT); lastT = t; |
| + |
| + // Pressure: build while pulled past the stop, decay when released/backed off. |
| + if (!snapped) { |
| + if (overpull && value >= 9.99) pressure = Math.min(1, pressure + dt / P.hold); |
| + else pressure = Math.max(0, pressure - dt / 300); |
| + if (pressure >= 1) snapToEleven(); |
| + } |
| + |
| + const level = levelNow(); |
| + stepDust(level); |
| + |
| + // Loudness shake on the whole card (shell.js dialPaint, CRANKED at eleven). |
| + if (!reduced && level > 0) { |
| + const mult = snapped ? P.crank : 1; |
| + const a = P.shake * level * mult; |
| + card.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 { |
| + card.style.transform = ""; |
| + } |
| + |
| + // The finish: wild for 620ms, bow for 330ms (dust fades mid-flight), then the |
| + // confirmation pane. Timing lifted from clickDialToEleven. |
| + if (snapped) { |
| + const dtSnap = t - snapT0; |
| + if (dust) dust.fade = Math.min(1, Math.max(0, 1 - (dtSnap - 620) / 330)); |
| + if (dtSnap > 950) { showDone(); return; } // stop the loop; pane is static |
| + } |
| + |
| + paint(); |
| + requestAnimationFrame(tick); |
| +} |
| + |
| +function snapToEleven() { |
| + // No travel, no ease — one frame it's at 10, the next it's AT ELEVEN. |
| + snapped = true; |
| + value = 10; |
| + pressure = 0; |
| + snapT0 = performance.now(); |
| + $("hint").textContent = "ELEVEN."; |
| +} |
| + |
| +function showDone() { |
| + card.style.transform = ""; |
| + if (dust) { dust.fade = 0; stepDust(0); } |
| + $("pitch").style.display = "none"; |
| + $("done").style.display = "block"; |
| +} |
| + |
| +// ---- dragging ------------------------------------------------------------------- |
| +function valueFromPointer(clientX) { |
| + const r = rail.getBoundingClientRect(); |
| + const f = (clientX - r.left) / r.width; |
| + return 1 + Math.min(1, Math.max(0, f / RUN_END)) * 9; // clamped to 1..10 |
| +} |
| +function onDown(e) { |
| + if (snapped) return; |
| + dragging = true; |
| + lever.classList.add("dragging"); |
| + handle.setPointerCapture?.(e.pointerId); |
| + onMove(e); |
| +} |
| +function onMove(e) { |
| + if (!dragging || snapped) return; |
| + const r = rail.getBoundingClientRect(); |
| + value = valueFromPointer(e.clientX); |
| + // The overpull: at the 10 stop AND still pulling meaningfully past it. |
| + overpull = value >= 9.99 && e.clientX > r.left + r.width * RUN_END + 14; |
| +} |
| +function onUp() { |
| + dragging = false; |
| + overpull = false; |
| + lever.classList.remove("dragging"); |
| +} |
| +handle.addEventListener("pointerdown", onDown); |
| +rail.addEventListener("pointerdown", onDown); |
| +addEventListener("pointermove", onMove); |
| +addEventListener("pointerup", onUp); |
| +addEventListener("pointercancel", onUp); |
| + |
| +// Keyboard fallback: arrows walk the notches; at 10, holding → for the hold |
| +// time arms eleven (End does not — eleven must be earned). |
| +handle.addEventListener("keydown", (e) => { |
| + if (snapped) return; |
| + if (e.key === "ArrowRight") { value = Math.min(10, Math.round(value) + 1); overpull = value >= 10; } |
| + else if (e.key === "ArrowLeft") { value = Math.max(1, Math.round(value) - 1); overpull = false; } |
| +}); |
| +handle.addEventListener("keyup", () => { overpull = false; }); |
| + |
| +// ---- reset + panel --------------------------------------------------------------- |
| +function resetLever() { |
| + snapped = false; value = 1; pressure = 0; overpull = false; |
| + if (dust) dust.fade = 1; |
| + $("pitch").style.display = ""; |
| + $("done").style.display = "none"; |
| + $("hint").textContent = "Slide it to eleven"; |
| + lastT = performance.now(); |
| + requestAnimationFrame(tick); |
| +} |
| +$("reset").addEventListener("click", resetLever); |
| +$("p-reset").addEventListener("click", resetLever); |
| + |
| +function bind(id, key, fmt) { |
| + $("p-" + id).addEventListener("input", (e) => { |
| + P[key] = +e.target.value; |
| + $("v-" + id).textContent = fmt ? fmt(P[key]) : P[key]; |
| + if (key === "motes") initDust(); // re-seed the cloud at the new density |
| + }); |
| +} |
| +bind("hold", "hold"); bind("shake", "shake"); bind("crank", "crank"); |
| +bind("kick", "kick", (v) => v.toFixed(2)); bind("motes", "motes"); |
| + |
| +addEventListener("resize", initDust); |
| +initDust(); |
| +requestAnimationFrame(tick); |
| +</script> |
| +</body> |
| +</html> |