eleven / messenger Public

home: plain SF + centered +; prototype the goes-to-eleven lever (#351)

Two page fixes and one prototype:

- Typeface: --font-display drops ui-rounded/"SF Pro Rounded" for the
  plain SF system stack (the rounded face read toy-like at heading sizes
  and was voted off). Create title softened 800→700.
- The + button's glyph was a text "+" sitting off its baseline; now an
  SVG plus, optically centered.

prototypes/space-lever.html — a local, self-contained practice page for
the new create-a-space interaction: a lever you slide left→right through
11 notches. The card shakes harder as the volume climbs (dust motes
bounce along the bottom of the viewport, physics lifted from shell.js's
boot amp knob); you can slide back down to calm it. The lever stops at
10 — keep pulling for ~a second (tweakable) and it SNAPS to eleven:
cranked shake, dust launched to mid-viewport, then the confirmation
pane. Includes a feel panel (hold time, shake, cranked multiplier, dust
kick, mote count) for live iteration. Nothing provisions — the blessed
feel gets ported into home's create screen where ELEVEN will fire
POST /api/spaces.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4327a14b78b56d4ab1732fe490cebbffef5afdce parent 3b513a1
3 files changed, +377 −5
  • home/web/pages/index.html +5 −4
  • home/web/static/home.css +3 −1
  • prototypes/space-lever.html +369 −0
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 3e22061..dd234b7 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -48,8 +48,9 @@
.icon-add {
flex: none; width: 30px; height: 30px; border-radius: 50%;
display: grid; place-items: center; padding: 0; border: none; cursor: pointer;
- background: var(--accent); color: var(--accent-ink); font-size: 22px; line-height: 1;
+ background: var(--accent); color: var(--accent-ink);
}
+ .icon-add svg { display: block; }
.icon-add:hover { filter: brightness(1.06); }
.icon-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Low-emphasis, full-width: Sign out (list) and Done (confirmation). */
@@ -63,8 +64,8 @@
#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: 24px; font-weight: 800;
- line-height: 1.15; letter-spacing: -0.02em; margin: 20px 0 22px;
+ font-family: var(--font-display); font-size: 23px; font-weight: 700;
+ line-height: 1.2; letter-spacing: -0.015em; margin: 20px 0 22px;
}
#create #new-space { width: 100%; }
/* Section headings: "Starred" and "More" — only shown once some are starred. */
@@ -153,7 +154,7 @@
<div id="vault" class="hidden screen">
<div class="vault-head">
<h2>Your Eleven spaces</h2>
- <button id="open-create" class="icon-add hidden" type="button" aria-label="Create a new space">+</button>
+ <button id="open-create" class="icon-add hidden" type="button" aria-label="Create a new space"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></button>
</div>
<input id="space-search" class="hidden" type="search" inputmode="search"
autocomplete="off" spellcheck="false" placeholder="Search spaces" aria-label="Search your spaces">
diff --git a/home/web/static/home.css b/home/web/static/home.css
index 100cb07..e8af88b 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -18,7 +18,9 @@
--shadow: light-dark(rgba(20, 30, 80, 0.10), rgba(0, 0, 0, 0.55));
--danger: light-dark(#c2410c, #f0936b);
--ok: light-dark(#237a41, #58b97e); /* dark enough for AA on the panel at status size */
- --font-display: ui-rounded, "SF Pro Rounded", "Avenir Next", -apple-system, system-ui, sans-serif;
+ /* Plain SF (system), not the rounded variant — the rounded display face read
+ as toy-like at heading sizes and was explicitly voted off the page. */
+ --font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
diff --git a/prototypes/space-lever.html b/prototypes/space-lever.html
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>