From 963a80afb4e8655ff8417cf21045210bd281b934 Mon Sep 17 00:00:00 2001 From: Alex Nodeland Date: Sat, 3 Oct 2026 11:12:26 -0400 Subject: [PATCH 01/10] feat(web): PERFORM's well and panel, EVOLVE's cards, stage mode and the guide pill (Plan-008 C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PERFORM was a single faceplate (a head with a small face and a scope, a row of first steps, eight dials, a touch row, six pads, the offer strip, XY and the hood), and EVOLVE showed waveforms where the approved specimen shows the sound's face. This lays both out to the specimen without dropping a function: - PERFORM's left column holds the cap (family · in hand, the family only where the engine has one), the name at the display size with share, the blurb, and the well with the held face drawn large (glow and reflection, as stage mode draws it). B's face stands beside it in amber while it holds an offer, with what changed and how to hear it under its floor, and Blend is a slider under the two faces. XY and How it works are modes of the well; the live trace sits in its corner. - The right column: CONTROLS with Arrange and How it works, the knob grid, the hood strip, and the pad row WANDER · OFFER · PEEK · TAKE · PASS. Freeze is a tap (or Enter) on Wander, shown on Wander. PASS is the pass Next already made, without growing another, with its UNDO. Keep and Back live in a "moved · KEEP · BACK" bar shown exactly when the sound has left home. What velocity plays is a row of Arrange, with a "vel" tick on its control. Blend and Wander keep their MIDI slots. - The pad keys (ADR-018): N offer/next, hold B peek, ⇧↵ take, ↵ keep with nothing focused, ⇧⌫ back; N is ANOTHER PAIR in EVOLVE. - The first steps move into the guide pill (guide.js), bottom left of the stage, one step at a time with ×; `auracle-perform-steps` migrates into `auracle-guide`, and both reset lists clear it. - EVOLVE: the question as the head with its pips, a small TASTE map (taste.drawMini), each card's face large in its well with ⇄ circuit and ↓ patch in the corner, the buttons relabelled (PLAY · 1, PICK A · ←, ANOTHER PAIR · N), EVOLVE POOL dashed until the first fit, and the lineage behind "what each generation did". - Stage mode: the wordmark and lamp top left, the family only where the engine has one, the keys printed as keys. Specs that pressed FREEZE, KEEP and BACK pads, read the XY field at rest, read three first steps at once, or dragged Blend as a dial now use Wander's tap, the moved bar, XY mode, the pill's one step and the slider. Claude-Session: https://claude.ai/code/session_01GMg3KFS9ggSrYdvGkbBshW --- apps/web/guide.js | 150 +++++ apps/web/index.html | 173 +++-- apps/web/main.js | 157 ++++- apps/web/perform.js | 599 +++++++++++++---- apps/web/style.css | 814 +++++++++++++++++------- apps/web/taste.js | 47 ++ apps/web/tests/guide.test.mjs | 35 + docs/decisions/018-the-pad-keys.md | 88 +++ tests/web/faces.spec.js | 4 +- tests/web/perform_controls.spec.js | 2 + tests/web/perform_offer_latency.spec.js | 2 +- tests/web/perform_offer_moments.spec.js | 2 +- tests/web/perform_open_early.spec.js | 14 +- tests/web/perform_palette.spec.js | 10 +- tests/web/perform_recentre.spec.js | 4 +- tests/web/perform_truth.spec.js | 23 +- tests/web/perform_wander.spec.js | 6 +- tests/web/responsive.spec.js | 14 +- tests/web/space_after_a_click.spec.js | 15 +- tests/web/text_fits.spec.js | 2 +- 20 files changed, 1674 insertions(+), 487 deletions(-) create mode 100644 apps/web/guide.js create mode 100644 apps/web/tests/guide.test.mjs create mode 100644 docs/decisions/018-the-pad-keys.md diff --git a/apps/web/guide.js b/apps/web/guide.js new file mode 100644 index 00000000..d4f923a5 --- /dev/null +++ b/apps/web/guide.js @@ -0,0 +1,150 @@ +// The guide pill (Plan-008 §1, "First-visit guide"; ADR-009's one onboarding +// surface): bottom left of the stage, one step at a time, with pips for how +// far along the steps are and × to stop showing them. A step ticks off when +// it happens, not when it is read. PERFORM's three first steps (play, turn a +// control, ask for an offer) are its steps today; the zoom and the model +// view's join it with the levels' zoom (Plan-008 PR C3). +// +// What has been done is the player's, kept in localStorage as +// `auracle-guide` ({done: [ids], closed}), JS-owned. The first steps kept +// their ticks as `auracle-perform-steps` (an array of ids) before the pill: +// read once into the new key and removed (`readGuide`). + +export const GUIDE_KEY = "auracle-guide"; +export const OLD_STEPS_KEY = "auracle-perform-steps"; + +/** What has been done, from storage: `get(key)` returns the stored string or + * null. The new key first; else the first steps' old array, migrated + * (`migrated` true: the caller writes the new key and removes the old). + * Anything unreadable is a fresh start. Pure, for the unit tests. */ +export function readGuide(get) { + const fresh = { done: [], closed: false, migrated: false }; + let raw = null; + try { + raw = get(GUIDE_KEY); + } catch { + return fresh; + } + if (raw != null) { + try { + const v = JSON.parse(raw); + const done = Array.isArray(v?.done) ? v.done.filter((x) => typeof x === "string") : []; + return { done: [...new Set(done)], closed: v?.closed === true, migrated: false }; + } catch { + return fresh; + } + } + let old = null; + try { + old = get(OLD_STEPS_KEY); + } catch { + return fresh; + } + if (old == null) return fresh; + try { + const v = JSON.parse(old); + const done = Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + return { done: [...new Set(done)], closed: false, migrated: true }; + } catch { + return { ...fresh, migrated: true }; + } +} + +/** The pill. `el` is its slot (`#guide`, in the stage). Steps are added in + * order with `add({id, text})`, `text()` giving the step's words now (a + * step may name what this sound can do). `done(id)` ticks one off. */ +export function createGuide({ el }) { + const steps = []; + let ending = 0; // the closing line's timer + const st = readGuide((k) => localStorage.getItem(k)); + const done = new Set(st.done); + let closed = st.closed; + const save = () => { + try { + localStorage.setItem(GUIDE_KEY, JSON.stringify({ done: [...done], closed })); + } catch { + /* a per-viewer convenience; in memory is enough for this visit */ + } + }; + if (st.migrated) { + save(); + try { + localStorage.removeItem(OLD_STEPS_KEY); + } catch { + /* private window */ + } + } + const make = (tag, cls, text) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + }; + const allDone = () => steps.length > 0 && steps.every((s) => done.has(s.id)); + // The pill: pips, the step, ×. A status, so a screen reader hears each new + // step once. + function render() { + if (ending) return; + el.innerHTML = ""; + const now = steps.find((s) => !done.has(s.id)); + if (closed || !now) { + el.classList.add("hidden"); + return; + } + el.classList.remove("hidden"); + const pill = make("div", "next pf-steps"); + pill.setAttribute("role", "status"); + const pips = make("span", "pips"); + pips.setAttribute("aria-hidden", "true"); + for (const s of steps) pips.append(make("i", done.has(s.id) ? "done" : "")); + const i = steps.indexOf(now); + const text = make("span", "pf-step now", now.text()); + text.dataset.step = now.id; + // The whole step where a narrow pill cuts it, and how far along it is. + text.title = `${now.text()} (step ${i + 1} of ${steps.length})`; + const x = make("button", "x", "×"); + x.type = "button"; + x.setAttribute("aria-label", "Stop showing these"); + x.title = "Stop showing these"; + x.onclick = () => { + closed = true; + save(); + render(); + }; + pill.append(pips, text, x); + el.append(pill); + } + return { + add(step) { + if (!steps.some((s) => s.id === step.id)) steps.push(step); + render(); + }, + done(id) { + if (done.has(id) || !steps.some((s) => s.id === id)) return; + const wasOpen = !closed && !allDone(); + done.add(id); + save(); + if (!allDone() || !wasOpen) return render(); + // The last step: its pip fills and one line says what the loop was, + // for a few seconds, then the pill goes. + el.innerHTML = ""; + el.classList.remove("hidden"); + const pill = make("div", "next pf-steps"); + pill.setAttribute("role", "status"); + const pips = make("span", "pips"); + pips.setAttribute("aria-hidden", "true"); + for (const s of steps) pips.append(make("i", "done")); + pill.append(pips, make("span", "pf-step done all", "That is the loop. Every offer you take or pass teaches it what you like.")); + el.append(pill); + ending = setTimeout(() => { + ending = 0; + render(); + }, 7000); + }, + // A step's words follow the sound (PERFORM's "Turn BRIGHT…"). + refresh() { + render(); + }, + isDone: (id) => done.has(id), + }; +} diff --git a/apps/web/index.html b/apps/web/index.html index 4b838cdf..237ecb30 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -21,7 +21,7 @@ - +