eleven / messenger Public

prototype: the create-space lever becomes a mixing-desk fader (#354)

Restyled the goes-to-eleven prototype as a channel fader: a recessed
slot with an inset shadow, a rectangular brushed cap with the classic
centre stripe (+ grip ridges), a tick scale 1-11 (major ticks at the
ends), and - the desk tell - a VU-style LED ladder above the slot:
green through 7, amber 8-9, red at 10, with a one-LED flicker while the
signal's hot so it reads as a live meter, not a progress bar. At ELEVEN
the whole ladder lights and the last LED strobes. Physics, dust, the
10-hold-snap, and the feel panel are unchanged.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 15b276db83102e7897ec6119099d601f82d05f0f parent cd94d02
1 file changed, +142 −71
  • prototypes/space-lever.html +142 −71
diff --git a/prototypes/space-lever.html b/prototypes/space-lever.html
index cc40d5c..bb1b2e6 100644
--- a/prototypes/space-lever.html
+++ b/prototypes/space-lever.html
@@ -1,5 +1,5 @@
<!doctype html>
-<!-- 🤖 Prototype: the "goes to eleven" create-a-space lever.
+<!-- 🤖 Prototype: the "goes to eleven" create-a-space fader.
A local practice page — open it straight from disk, no server, nothing is
provisioned. The plan: iterate the FEEL here (drag physics, shake, dust,
@@ -7,18 +7,21 @@
(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,
+ Styled as a mixing-desk channel fader: a recessed slot, a rectangular cap
+ with the classic centre stripe, a tick scale 1–11, and a VU-style LED
+ ladder that dances with the level. The physics are lifted from the
+ product's boot amp knob (web/static/shell.js: stepDust/dialPaint/
+ clickDialToEleven): 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>
+<title>Space fader — prototype</title>
<style>
- /* Tokens copied from home.css (classic-neutral, indigo accent). */
+ /* Tokens copied from home.css (classic-neutral, indigo accent), plus VU
+ colours — the one place real desk vernacular (green/amber/red) beats brand. */
:root {
color-scheme: light dark;
--bg: light-dark(#f6f7fb, #15171c);
@@ -31,6 +34,10 @@
--quiet: light-dark(#eef0f6, #2a2e38);
--shadow: light-dark(rgba(20,30,80,.10), rgba(0,0,0,.55));
--ok: light-dark(#237a41, #58b97e);
+ --led-g: light-dark(#31a24c, #3fd063);
+ --led-a: light-dark(#e8a23a, #f0b45a);
+ --led-r: light-dark(#e5484d, #ff5d63);
+ --slot: light-dark(#d3d7e2, #0e1014);
}
* { box-sizing: border-box; }
body {
@@ -44,45 +51,82 @@
.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));
+ padding: 28px; width: min(440px, 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; }
+ p.sub { color: var(--muted); font-size: 14px; margin: 0 0 22px; }
- /* ---- 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;
+ /* ---- the channel strip --------------------------------------------------- */
+ .strip {
+ background: var(--quiet); border: 1px solid var(--border);
+ border-radius: 14px; padding: 16px 20px 12px;
}
- .fill {
- position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 999px;
- background: var(--accent);
+ /* VU ladder: one LED per notch. Unlit LEDs are faint wells; lit ones glow in
+ desk colours — green through 7, amber 8–9, red at 10, and ELEVEN is red and
+ furious. A touch of flicker while the signal's hot sells the VU. */
+ .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;
}
- /* 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);
+ .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; }
+
+ /* The fader: a recessed slot, ticks above it, numbers below, and the cap. */
+ .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;
}
- .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 {
+ .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); }
+
+ /* The cap: rectangular, brushed, with the classic centre stripe. */
+ .cap {
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;
+ 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 { /* the centre line every fader cap wears */
+ position: absolute; top: 6px; bottom: 6px; left: 50%; width: 3px; translate: -50% 0;
+ border-radius: 2px; background: var(--accent); z-index: 1;
}
- .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); }
+ .fader.dragging .cap { cursor: grabbing; }
+ .cap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+ .hint { margin: 30px 0 0; font-size: 13px; color: var(--muted); }
/* success pane (single-serve confirmation mock) */
#done { display: none; }
@@ -118,12 +162,15 @@
<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 class="strip">
+ <div class="meter" id="meter"></div>
+ <div class="fader" id="fader">
+ <div class="slot"></div>
+ <div class="cap" id="cap" role="slider" aria-label="Slide to eleven"
+ aria-valuemin="1" aria-valuemax="11" aria-valuenow="1" tabindex="0"><div class="stripe"></div></div>
+ </div>
</div>
- <p class="hint" id="hint">Slide it to eleven</p>
+ <p class="hint" id="hint">Slide the fader to eleven</p>
</div>
<div id="done">
@@ -141,7 +188,7 @@
<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>
+ <button id="p-reset">Reset fader</button>
</div>
<script>
@@ -154,7 +201,7 @@ const P = {
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 RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 past the gap
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const $ = (id) => document.getElementById(id);
@@ -199,43 +246,67 @@ function stepDust(level) {
g.globalAlpha = 1;
}
-// ---- lever state ---------------------------------------------------------------
+// ---- fader 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 snapped = false; // reached ELEVEN (locks the fader, plays the finish)
let snapT0 = 0;
let lastT = performance.now();
-const lever = $("lever"), rail = $("rail"), fill = $("fill"), handle = $("handle"), card = $("card");
+const fader = $("fader"), cap = $("cap"), card = $("card"), meter = $("meter");
-// Notch layout: 1..10 across RUN_END, a gap, then 11 at the very end.
-const notches = [];
+// Scale: ticks + numbers for 1..10 across RUN_END, a gap, then 11 at the end.
+const nums = [];
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);
+ const left = (n === 11 ? 100 : ((n - 1) / 9) * RUN_END * 100) + "%";
+ const tick = document.createElement("span");
+ tick.className = "tick" + (n === 1 || n === 11 ? " major" : "");
+ tick.style.left = left;
+ fader.appendChild(tick);
+ const num = document.createElement("span");
+ num.className = "num" + (n === 11 ? " eleven" : "");
+ num.textContent = n;
+ num.style.left = left;
+ fader.appendChild(num);
+ nums.push(num);
}
-function xForValue(v) { // rail-fraction for a 1..10 value
+// The LED ladder: one per notch, coloured like a desk meter.
+const leds = [];
+for (let n = 1; n <= 11; n++) {
+ const led = document.createElement("span");
+ led.className = "led";
+ led.dataset.c = n <= 7 ? "g" : n <= 9 ? "a" : "r";
+ meter.appendChild(led);
+ leds.push(led);
+}
+
+function xForValue(v) { // slot-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);
+ cap.style.left = (fx * 100) + "%";
+ cap.setAttribute("aria-valuenow", snapped ? 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 && !snapped);
+ nums[10].classList.toggle("hit", snapped);
+
+ // VU: lit up to the current notch, with a one-LED flicker while the signal's
+ // hot so it reads as a live meter, not a progress bar. At eleven: all of it,
+ // and the last LED strobes.
+ const hot = levelNow();
+ const lit = snapped ? 11 : Math.round(value) + (hot > 0.4 && Math.random() < 0.35 ? (Math.random() < 0.5 ? -1 : 0) : 0);
+ leds.forEach((led, i) => {
+ const on = i < lit;
+ led.className = "led" + (on ? " on-" + led.dataset.c : "") +
+ (snapped && i === 10 ? " fury" : "");
+ });
}
// level drives shake + dust, mirroring dialPaint: silent below ~5, trembling by
@@ -300,20 +371,20 @@ function showDone() {
// ---- dragging -------------------------------------------------------------------
function valueFromPointer(clientX) {
- const r = rail.getBoundingClientRect();
+ const r = fader.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);
+ fader.classList.add("dragging");
+ cap.setPointerCapture?.(e.pointerId);
onMove(e);
}
function onMove(e) {
if (!dragging || snapped) return;
- const r = rail.getBoundingClientRect();
+ const r = fader.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;
@@ -321,22 +392,22 @@ function onMove(e) {
function onUp() {
dragging = false;
overpull = false;
- lever.classList.remove("dragging");
+ fader.classList.remove("dragging");
}
-handle.addEventListener("pointerdown", onDown);
-rail.addEventListener("pointerdown", onDown);
+cap.addEventListener("pointerdown", onDown);
+fader.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) => {
+cap.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; });
+cap.addEventListener("keyup", () => { overpull = false; });
// ---- reset + panel ---------------------------------------------------------------
function resetLever() {
@@ -344,7 +415,7 @@ function resetLever() {
if (dust) dust.fade = 1;
$("pitch").style.display = "";
$("done").style.display = "none";
- $("hint").textContent = "Slide it to eleven";
+ $("hint").textContent = "Slide the fader to eleven";
lastT = performance.now();
requestAnimationFrame(tick);
}