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>
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); |
| } |