Bright, Snap, Motion, Body, Grit, and Space are named for what you hear, and Arrange places up to eight of the palette’s eighteen, in your order. Each is wired to the knobs of this sound that do that job, measured when it opens, and the strip underneath shows them move. An amber control is one this sound’s knobs can’t reach: turn it anyway, and it adds what is missing or grows a variant that can. A control that says listening… hasn’t been measured on this sound yet, and does nothing until it has.
-Offer grows a new version of what you are playing into B. Hold Peek to hear it, turn Blend to move between the two, and Take to make it yours. While B holds one, the pad reads Next: it passes on B and brings the next. Once you have heard an offer, either answer teaches the model, and either can be taken back from its note.
-Wander lets the model play along: a little brings ideas into B, more drifts the knobs toward your taste, and all the way roams. Let go of it and it answers in a moment, and the line under it says what it is doing. Keep marks where you are and Back returns there; Freeze stops Wander where it is. The XY pad puts two controls under one finger, and a MIDI controller’s first eight knobs claim the deck’s first eight controls.
+Offer (n) grows a new version of what you are playing into B, beside it in the well. Hold Peek (or b) to hear it, slide Blend under the two to move between them, and Take (⇧↵) to make it yours. While B holds one, the pad reads Next: it passes on B and brings the next; Pass passes without another. Once you have heard an offer, either answer teaches the model, and either can be taken back from its note.
+Wander, at the start of the pads, lets the model play along: a little brings ideas into B, more drifts the knobs toward your taste, and all the way roams. Let go of it and it answers in a moment, and the line under it says what it is doing; tap it to freeze it. When the sound has moved from where you last kept it, moved · keep · back shows by its name: Keep (↵, with nothing focused) marks where you are and Back (⇧⌫) returns there. XY in the well’s corner puts two controls under one finger, Arrange chooses what your velocity plays, and a MIDI controller’s first eight knobs claim the first eight controls.
The levels at the right edge say where you are, and a click on one goes there. ⌥↑ zooms out (PATCH, PERFORM, TASTE, LEARNING) and ⌥↓ in · ⌥← goes beside it to EVOLVE and ⌥→ back · ⌥1–5 go straight to PERFORM, PATCH, EVOLVE, TASTE or LEARNING · hold ⌥ (or model) for the model view: what it believes, over any level; a tap on model keeps it, Esc ends it
a w s e d f t g y h u j k play notes · z/x octave · ⌘Z take back a pick or cut (7 s), else in PATCH undo an edit · ⇧⌘Z redo
-Space hear the sound you’re playing · in PERFORM, ⇧F stage mode, and ? on a control shows what it does to this sound, measured (BRIGHT’s has a lesson on filters) · [/] step through the bank · 1–5 rate the sound you’re on · m save it · in the bank, c compares a bred sound with its seed · in presets, p plays the row
-In EVOLVE: 1/2 hear A or B · ←/→ pick · ⌘Z take back a pick (7 s, the sixth too)
+Space hear the sound you’re playing · in PERFORM, n offer, hold b to peek, ⇧↵ take, ↵ keep, ⇧⌫ back, ⇧F stage mode, and ? on a control shows what it does to this sound, measured (BRIGHT’s has a lesson on filters) · [/] step through the bank · 1–5 rate the sound you’re on · m save it · in the bank, c compares a bred sound with its seed · in presets, p plays the row
+In EVOLVE: 1/2 hear A or B · ←/→ pick · n another pair · ⌘Z take back a pick (7 s, the sixth too)
Home fit the whole patch · . fit what you’re on · ⌘0 actual size · ⌘−/⌘= zoom out/in
Ctrl and the wheel, or a pinch, zooms at the pointer · the wheel or a drag on bare canvas pans · Space and a drag, or a middle-button drag, pans from anywhere on it. map shows where you are; detail drops the knobs when you zoom out.
diff --git a/apps/web/main.js b/apps/web/main.js index 33e509f9..06f11f76 100644 --- a/apps/web/main.js +++ b/apps/web/main.js @@ -162,11 +162,20 @@ const shell = createShell({ // Any modal one showing: the warm start, the commit pair, the ? card, // PERFORM's stage mode, explain's lesson. A non-modal panel (MIDI, KEYS ⋯, // the scope's settings, Compare) does not keep them. - blocked: () => - [...document.querySelectorAll('[aria-modal="true"]')].some( - (el) => el.isConnected && !el.classList.contains("hidden") && el.getClientRects().length > 0, - ), + blocked: () => modalUp(), }); +/** Is a modal dialog showing (the warm start, the commit pair, the ? card, + * stage mode, explain's lesson)? It keeps the level keys and PERFORM's pad + * keys: what is behind it must not change unseen. */ +function modalUp() { + return [...document.querySelectorAll('[aria-modal="true"]')].some( + (el) => el.isConnected && !el.classList.contains("hidden") && el.getClientRects().length > 0, + ); +} +// The guide pill (guide.js): the first-visit steps, one at a time, bottom +// left of the stage. PERFORM adds its three when it is built. +const { createGuide } = await import(`./guide.js?v=${BUILD}`); +const guide = createGuide({ el: $("guide") }); for (const level of ["perform", "patch", "evolve", "taste", "learning"]) shell.register(level, { el: $(`view-${level}`) }); const worker = new Worker(`./worker.js?v=${BUILD}`, { type: "module" }); const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); @@ -373,6 +382,30 @@ const FACE_SIZE = { warm: [24, 40], // the warm start's cards share: [150, 230], // the sound's card (drawn into its SVG, not a slot) inhand: [20, 30], // the header's chip, the sound in hand + // Large, in a well (Plan-008 C1): PERFORM's sound in hand and its B, and + // an EVOLVE card's sound. Drawn at this size and scaled by the well that + // holds it (`FACE_FLUID`), with the glow and the floor's reflection stage + // mode draws (`FACE_OPTS`). + well: [240, 480], + wellb: [200, 480], + evolve: [240, 480], +}; +const FACE_FLUID = new Set(["well", "wellb", "evolve"]); +// A large face's vessel stands on a floor at 79% of its picture, with room +// under it for its reflection, as stage mode's does (perform.js +// `stageBox`), 0.6 as wide as it is tall. B's is the model's, in amber, and +// smaller, on the same floor: the two pictures share a height in the well, +// so the floors line up (style.css places B's label and words by these +// numbers). +const wellBox = (w, h, scale = 1) => { + const bh = h * 0.74 * scale; + const bw = Math.min(w * 0.9, bh * 0.6); + return { x: (w - bw) / 2, y: h * 0.79 - bh, w: bw, h: bh }; +}; +const FACE_OPTS = { + well: () => ({ box: wellBox(...FACE_SIZE.well), glow: 18, reflection: true, line: 2, dprMax: 2 }), + wellb: () => ({ box: wellBox(...FACE_SIZE.wellb, 0.62), glow: 12, reflection: true, line: 1.6, color: tok("--phos-b"), dprMax: 2 }), + evolve: () => ({ box: wellBox(...FACE_SIZE.evolve), glow: 18, reflection: true, line: 2, dprMax: 2 }), }; // Bounded: a bench edit is a new tree, so a new ref, key and drawing each // time. The least recently used go past FACE_KEEP (the bank's faces are used @@ -386,10 +419,10 @@ function lruGet(map, k) { map.set(k, v); return v; } -function lruSet(map, k, v) { +function lruSet(map, k, v, keep = FACE_KEEP) { map.delete(k); map.set(k, v); - while (map.size > FACE_KEEP) map.delete(map.keys().next().value); + while (map.size > keep) map.delete(map.keys().next().value); } const faceByKey = new Map(); // "The amber dot on a control’s ring is where this sound measures on it, compared with the sounds in your session. A control that turns only one way on this sound says so under its name (turns toward far only): its ring is solid on that side, and it stops at the center on the other. One that reads listening… hasn’t been measured on this sound yet, and does nothing until it has.
" + "A control drawn in amber can’t be reached by this patch’s knobs (a patch with no drive can’t get grittier by turning a filter). Turn it past the notch and let go, and it adds what is missing or asks for a variant that can, aimed the way you turned it, which arrives in B saying how far it went (grittier by 1.8σ, σ being the spread of your session’s sounds) or that it didn’t get there. Short of the notch it springs back and asks nothing.
" + "Wander sets how alive the sound is: still, ideas (variants appear in B), drift (knob-only steps of the taste walk, glided, about one per phrase), roam (bigger, faster). Its ticks mark where each begins. Let go of it in a new zone and it answers in a second and a half; the line under it says what it is doing and when it moves next, and the thin arc inside its ring fills toward that move. Structure never changes on its own. Tap Wander to freeze it; touching any other control pauses it for a few seconds.
"; - whyBody.append(howList, howOne, howAll); + whyBody.append(whyX, howList, howOne, howAll); // Which control How it works opens on: the one last touched (a palette // index), else the panel's first. let howAt = null; @@ -2913,13 +3149,51 @@ export function createPerform(host) { h.append(el("span", "pf-how-name", c.name), el("span", "pf-how-ends mono", `${c.low} · ${c.high}`)); howOne.append(h, el("p", "pf-how-hear", c.hear), el("p", "pf-how-how", c.how), el("p", "pf-how-here", on)); } - whyBtn.onclick = () => { - whyBody.classList.toggle("hidden"); - whyBtn.setAttribute("aria-expanded", String(!whyBody.classList.contains("hidden"))); - renderHow(); - }; + whyBtn.onclick = () => setWellMode(wellMode === "how" ? "face" : "how"); whyBtn.setAttribute("aria-expanded", "false"); - why.append(whyBtn, whyBody); + well.append(whyBody); + + // ---------- the well's modes ---------- + // The face (at rest), the XY pad or How it works, one at a time. The + // face stays drawn, dimmed, behind the other two. Esc inside the well, or + // the mode's own button again, puts the face back. + let wellMode = "face"; + const xyBtn = el("button", "pf-xy-btn util-btn", "XY"); + xyBtn.type = "button"; + xyBtn.title = "XY pad: two controls under one finger"; + xyBtn.setAttribute("aria-pressed", "false"); + xyBtn.setAttribute("aria-controls", "pf-xy"); + xy.id = "pf-xy"; + xyBtn.onclick = () => setWellMode(wellMode === "xy" ? "face" : "xy"); + function setWellMode(mode) { + const was = wellMode; + wellMode = mode; + well.dataset.mode = mode; + xy.classList.toggle("hidden", mode !== "xy"); + whyBody.classList.toggle("hidden", mode !== "how"); + xyBtn.setAttribute("aria-pressed", String(mode === "xy")); + whyBtn.setAttribute("aria-expanded", String(mode === "how")); + if (mode === "how") renderHow(); + if (mode === "xy") { + paintXY(); + if (was !== "xy") xyField.focus({ preventScroll: true }); + } + // Focus left inside a mode that closed goes to the button that opens it. + if (was !== mode && was !== "face" && well.contains(document.activeElement) && !document.activeElement.closest(".pf-corner")) { + (was === "xy" ? xyBtn : whyBtn).focus({ preventScroll: true }); + } + } + well.addEventListener("keydown", (e) => { + if (e.key !== "Escape" || wellMode === "face") return; + setWellMode("face"); + }); + // A click on the sound plays it, as Space does (the specimen's well), and + // stage mode's tap: on a touch screen there is no Space. + // A click on B is not on the sound in hand: B is heard with PEEK or B. + faces.addEventListener("click", (e) => { + if (wellMode !== "face" || e.target.closest("button, input, .pf-offer")) return; + host.play?.(); + }); // ---------- the palette ---------- // Which controls sit on the panel, and in what order: place, hide and order @@ -2930,7 +3204,7 @@ export function createPerform(host) { // control not on the panel is not measured, so it carries no mark. // Marks sit in a cell every row reserves, left of the name: a name never // moves for a mark, and is never cut short by one. - const arrangeBtn = el("button", "pf-arrange util-btn", "arrange"); + const arrangeBtn = el("button", "pf-arrange pf-disclose", "arrange"); arrangeBtn.type = "button"; arrangeBtn.title = "Place, hide and order your controls"; arrangeBtn.setAttribute("aria-haspopup", "dialog"); @@ -3027,7 +3301,8 @@ export function createPerform(host) { } function renderPalette() { if (!palOpen()) return; - const keep = pal.contains(document.activeElement) ? document.activeElement.getAttribute("aria-label") : null; + const inTouch = touchRow.contains(document.activeElement) ? document.activeElement : null; + const keep = !inTouch && pal.contains(document.activeElement) ? document.activeElement.getAttribute("aria-label") : null; const n = state.panel.length; pal.innerHTML = ""; const head = el("div", "pp-head"); @@ -3042,7 +3317,11 @@ export function createPerform(host) { const body = el("div", "pp-body"); const on = el("div", "pp-sec"); on.append(el("div", "pp-h", "On the panel"), ...state.panel.map((k, i) => palRow(k, i))); - body.append(on); + // What a note's velocity plays: one of the panel's controls that turns + // both ways on this sound, or loudness only (`renderTouch`). + const vel = el("div", "pp-sec pp-vel"); + vel.append(el("div", "pp-h", "Velocity plays"), touchRow); + body.append(on, vel); for (const fam of FAMILIES) { const rest = PALETTE.map((c, k) => k).filter((k) => PALETTE[k].family === fam && !state.panel.includes(k)); if (!rest.length) continue; @@ -3052,7 +3331,8 @@ export function createPerform(host) { } pal.append(head, body); // Focus stays on the button it was on, or the row's next useful one. - if (keep) { + if (inTouch) inTouch.focus({ preventScroll: true }); + else if (keep) { const b = [...pal.querySelectorAll("button")].find((e) => e.getAttribute("aria-label") === keep && !e.disabled); (b || x).focus({ preventScroll: true }); } @@ -3086,14 +3366,18 @@ export function createPerform(host) { // in the vessel's own coordinates, left to fade like phosphor (`stageDraw`). // Nothing moves without sound. Space plays and the keys play, as // everywhere; ⇧F or Esc leaves. Not a modal: it asks nothing. - const stageBtn = el("button", "pf-stage-btn util-btn", "stage ⇧F"); + const stageBtn = el("button", "pf-stage-btn util-btn"); stageBtn.type = "button"; stageBtn.title = platformKeys("Stage mode · ⇧F"); - stageBtn.textContent = platformKeys("stage ⇧F"); + stageBtn.setAttribute("aria-label", platformKeys("Stage mode (⇧F)")); + stageBtn.setAttribute("aria-keyshortcuts", "Shift+F"); + stageBtn.innerHTML = ''; + stageBtn.append(el("kbd", "pf-kbd", platformKeys("⇧F"))); stageBtn.onclick = () => openStage(); - const actions = el("div", "pf-actions"); - actions.append(arrangeBtn, stageBtn); - head.insertBefore(actions, scope); + // CONTROLS' head: Arrange and How it works. The well's corner: the XY + // pad and stage mode. + ctlActs.append(arrangeBtn, whyBtn); + corner.append(xyBtn, stageBtn); let stageOn = null; // {root, cv, trail, name, raf, entered, back, …} function openStage() { if (stageOn) return; @@ -3109,13 +3393,28 @@ export function createPerform(host) { cv.setAttribute("aria-hidden", "true"); const trail = el("canvas", "st-canvas st-trail"); trail.setAttribute("aria-hidden", "true"); + // The wordmark and the model's lamp, top left, as the specimen stages it + // (`a-stage.png`): the lamp is the header's, lit while the model works. + const brand = el("div", "st-brand"); + brand.setAttribute("aria-hidden", "true"); + const lamp = el("span", "lamp st-lamp"); + brand.append(el("span", "word", "AURACLE"), lamp); const hud = el("div", "st-hud"); const name = el("div", "st-name", host.label()); - const where = el("div", "st-cat mono", "PERFORM · in hand"); + // The family only where the engine has one (a preset's category): "bass · + // in hand", or "in hand". + const fam = host.family?.() || ""; + const where = el("div", "st-cat mono", fam ? `${fam.toLowerCase()} · in hand` : "in hand"); hud.append(name, where); - // What plays and what leaves, in the hands this screen has. + // What plays and what leaves, in the hands this screen has, each key + // printed as a key. const touchOnly = !!window.matchMedia?.("(pointer: coarse)").matches; - const hint = el("div", "st-hint mono", touchOnly ? "tap to play the sound · × leaves" : platformKeys("A to L play · Space plays the sound · ⇧F or Esc leaves")); + const hint = el("div", "st-hint mono"); + if (touchOnly) hint.textContent = "tap to play the sound · × leaves"; + else { + const k = (t) => el("kbd", "st-kbd", platformKeys(t)); + hint.append(k("A"), " to ", k("L"), " play · ", k("Space"), " plays the sound · ", k("⇧F"), " or ", k("Esc"), " leaves"); + } const leave = el("button", "st-leave util-btn", "×"); leave.type = "button"; leave.setAttribute("aria-label", platformKeys("Leave stage mode (⇧F or Esc)")); @@ -3130,7 +3429,7 @@ export function createPerform(host) { // outside a modal dialog, so a refusal is said here too (below). const ticks = ["100 Hz", "1 kHz", "10 kHz"].map((t) => el("div", "st-tick mono", t)); ticks.forEach((t) => t.setAttribute("aria-hidden", "true")); - rootEl.append(cv, trail, ...ticks, hud, hint, leave, said); + rootEl.append(cv, trail, ...ticks, brand, hud, hint, leave, said); // A tap plays the sound, as Space does: on a touch screen there is no // Space. rootEl.addEventListener("click", (e) => { @@ -3146,7 +3445,7 @@ export function createPerform(host) { behind.forEach((e) => (e.inert = true)); document.body.append(rootEl); document.documentElement.classList.add("st-on"); - stageOn = { root: rootEl, cv, trail, name, ticks, hint, leave, said, hintText: hint.textContent, hintTimer: 0, raf: 0, entered: false, back, behind }; + stageOn = { root: rootEl, cv, trail, name, lamp, ticks, hint, leave, said, hintNodes: [...hint.childNodes], hintTimer: 0, raf: 0, entered: false, back, behind }; rootEl.focus({ preventScroll: true }); // A refusal is said in a toast, which stage mode would hide: urgent ones // are lifted above it (style.css), and said in the stage's own line too, @@ -3164,7 +3463,7 @@ export function createPerform(host) { st.said.textContent = text; clearTimeout(st.hintTimer); st.hintTimer = setTimeout(() => { - st.hint.textContent = st.hintText; + st.hint.replaceChildren(...st.hintNodes); st.hint.classList.remove("said"); }, 5000); } @@ -3202,7 +3501,9 @@ export function createPerform(host) { // was (ADR-009's status note). Matched on the key's character, as the // keymap is, so a Dvorak or Colemak F is F. Only Shift and F, nothing else // held, and never while typing. Esc leaves; Tab stays on the stage. - const typing = (t) => !!t?.closest?.("input, textarea, select, [contenteditable]"); + // A range input (Blend) is a control, not text entry: the keys stay, as + // main.js keeps the note keys for it. + const typing = (t) => !!t?.closest?.("input:not([type=range]), textarea, select, [contenteditable]"); window.addEventListener( "keydown", (e) => { @@ -3227,6 +3528,70 @@ export function createPerform(host) { }, true, ); + // ---------- the pad keys (ADR-018) ---------- + // N offers (Next while B holds one), B held peeks, ⇧↵ takes, ↵ keeps and + // ⇧⌫ goes back. PERFORM's only, in sight, never over a modal (the warm + // start, the ? card, stage mode, a lesson) and never while typing. ↵ Keep + // only with no control focused: a focused control keeps its own Enter (a + // button presses, a control's long-press sweep). ⇧↵ is not a control's + // key, so it takes from a focused dial too, but not from a button or a + // drop-down, where Enter is theirs. Freeze has no key: a tap on Wander. + // Matched by character, as the keymap is (N and B are no note's key). + const ENTER_OWNERS = "button, a[href], select, textarea, input:not([type=range]), [contenteditable], [role=button], [role=tab], [role=menuitem], [role=option], [role=listbox]"; + const padKeysOff = (e) => !state.visible || !!stageOn || typing(e.target) || !!host.blocked?.() || e.metaKey || e.ctrlKey || e.altKey; + const nothingFocused = () => { + const a = document.activeElement; + return !a || a === document.body || a === document.documentElement || a === root; + }; + let peekKey = false; + document.addEventListener("keydown", (e) => { + if (e.defaultPrevented || padKeysOff(e)) return; + const k = e.key; + if ((k === "n" || k === "N") && !e.shiftKey) { + e.preventDefault(); + if (e.repeat) return; + touch(); + requestOffer(); + flash("offer"); + } else if ((k === "b" || k === "B") && !e.shiftKey) { + e.preventDefault(); + if (e.repeat || peekKey) return; + peekKey = true; + touch(); + padEls.peek?.classList.add("down"); + peekOn(); + } else if (k === "Enter" && e.shiftKey) { + if (e.target?.closest?.(ENTER_OWNERS)) return; + e.preventDefault(); + if (e.repeat) return; + touch(); + take(); + } else if (k === "Enter" && !e.shiftKey) { + if (!nothingFocused()) return; + e.preventDefault(); + if (e.repeat) return; + if (!movedFromHome()) return void host.note("Nothing to keep: this sound is home.", { replace: "pf-keep", urgent: true }); + touch(); + keep(); + } else if (k === "Backspace" && e.shiftKey) { + e.preventDefault(); + if (e.repeat) return; + if (!movedFromHome()) return void host.note("Nothing to go back to: this sound is home.", { replace: "pf-keep", urgent: true }); + touch(); + back(); + } + }); + const endPeekKey = () => { + if (!peekKey) return; + peekKey = false; + padEls.peek?.classList.remove("down"); + peekOff(); + }; + document.addEventListener("keyup", (e) => { + if (e.key === "b" || e.key === "B") endPeekKey(); + }); + window.addEventListener("blur", endPeekKey); + // The loop: size the canvas, follow the name, and hand one frame's state // to `stageDraw`, which is all that knows what the stage looks like. function stageFrame() { @@ -3243,6 +3608,9 @@ export function createPerform(host) { } } if (s.name.textContent !== host.label()) s.name.textContent = host.label(); + // The lamp says what the header's does: lit while the model works. + const thinking = !!document.getElementById("wm-lamp")?.classList.contains("thinking"); + if (s.lamp.classList.contains("thinking") !== thinking) s.lamp.classList.toggle("thinking", thinking); const tree = state.cur ? state.cur.json : null; stageDraw(s.cv.getContext("2d"), { W, @@ -3377,13 +3745,14 @@ export function createPerform(host) { // ---------- first steps ---------- // For someone who walks up to it cold — no staff, no manual. Three moves // are the whole loop: play, turn a control, ask for an offer. Each ticks off - // when it happens (not when it is read), and the strip retires once all - // three have. Per visitor: the booth's "new visitor" brings it back. - const STEPS_KEY = "auracle-perform-steps"; + // when it happens (not when it is read), and the guide pill (guide.js, + // `host.guide`) retires once all three have. Per visitor: the booth's "new + // visitor" brings it back. + const coarse = () => !!window.matchMedia?.("(pointer: coarse)").matches; const STEPS = [ { id: "play", text: () => "Play a key: A to L, or tap the keybed" }, { id: "turn", text: turnStep }, - { id: "offer", text: () => "Press OFFER, then hold PEEK or TAKE it" }, + { id: "offer", text: () => (coarse() ? "Press OFFER, then hold PEEK or TAKE it" : "Press OFFER or N, then hold B to peek, or TAKE it") }, ]; // Step 2 names a control that turns on *this* patch. It used to say "Turn a // lit control: BRIGHT is a good start" on every patch, when the only @@ -3400,39 +3769,18 @@ export function createPerform(host) { if (one != null) return `Turn ${controls()[one].name.toUpperCase()}: drag ${rangeOf(w[one])[1] > 0 ? "up" : "down"}`; return "Turn a named control: drag up or down"; } - const stepsDone = new Set(); - try { - for (const id of JSON.parse(localStorage.getItem(STEPS_KEY) || "[]")) stepsDone.add(id); - } catch { - /* a per-viewer convenience; an empty set is fine */ - } + for (const st of STEPS) host.guide?.add(st); + // The pill's step 2 names what this sound can turn: said again when the + // wiring changes. function renderSteps() { - if (stepsDone.size >= STEPS.length) { - // The closing line stays for its moment (see stepDone). - if (!stepsEl.querySelector(".all")) stepsEl.classList.add("hidden"); - return; - } - stepsEl.innerHTML = ""; - const now = STEPS.find((st) => !stepsDone.has(st.id)); - STEPS.forEach((st, i) => { - const done = stepsDone.has(st.id); - stepsEl.append(el("span", `pf-step${done ? " done" : ""}${st === now ? " now" : ""}`, `${done ? "✓" : i + 1} ${st.text()}`)); - }); + host.guide?.refresh(); } + // Booth attract plays the instrument by itself: nothing it does ticks a + // visitor's step. function stepDone(id) { - if (state.quiet || stepsDone.has(id) || stepsDone.size >= STEPS.length) return; - stepsDone.add(id); - try { - localStorage.setItem(STEPS_KEY, JSON.stringify([...stepsDone])); - } catch { - /* in memory is enough for this visit */ - } - if (stepsDone.size < STEPS.length) return renderSteps(); - stepsEl.innerHTML = ""; - stepsEl.append(el("span", "pf-step done all", "✓ That is the loop. Every offer you take or pass teaches it what you like.")); - setTimeout(() => stepsEl.classList.add("hidden"), 7000); + if (state.quiet) return; + host.guide?.done(id); } - renderSteps(); // ---------- XY pad ---------- // Named-control indices on each axis. Until the player picks, the pad @@ -3475,8 +3823,11 @@ export function createPerform(host) { }); sel.value = String(XY[axis]); } - xyHead.append(el("span", "pf-xy-cap", "XY"), axisSel("x"), el("span", null, "×"), axisSel("y")); + // The axis pickers along the well's top edge; the end words at the field's + // edges. Hidden until the well is in XY mode (`setWellMode`). + xyHead.append(axisSel("x"), el("span", "pf-xy-by", "×"), axisSel("y")); xy.append(xyHead, xyField); + xy.classList.add("hidden"); const xyReach = (i) => turns(state.wire && state.wire[i]); // An axis not measured yet (no wiring, or a control a Take carried over with @@ -3586,17 +3937,20 @@ export function createPerform(host) { clearTimeout(k.keyTimer); clearTimeout(k.bumpTimer); } + for (const k of knobs) if (k.spec.kind !== "named") k.wrap.remove(); knobs.length = 0; deck.innerHTML = ""; controls().forEach((c, i) => makeKnob(i, { kind: "named", name: c.name, low: c.low, high: c.high, index: state.panel[i], initial: state.c[i] || 0 }), ); const n = state.panel.length; - makeKnob(n, { kind: "blend", name: "Blend", low: "home", high: "offer", initial: state.blend }); + // Blend and Wander keep the slots after the panel's controls (MIDI's and + // the keys' `setControl`), wherever they are drawn. + makeBlend(n, { kind: "blend", name: "Blend", low: "home", high: "offer", initial: state.blend }); makeKnob(n + 1, { kind: "wander", name: "Wander", low: "still", high: "roam", initial: state.wander }); - // Eight columns hold the six and Blend and Wander; a longer panel widens - // the row by a column a control (a count, not a size). - deck.style.setProperty("--pf-cols", String(Math.max(8, n + 2))); + // Three columns hold the six, as the specimen sets them; a longer panel + // takes four (a count, not a size). + deck.style.setProperty("--pf-cols", String(n <= 6 ? 3 : 4)); knobs.forEach(paintKnob); } buildDeck(); @@ -3718,32 +4072,22 @@ export function createPerform(host) { renderPalette(); } - pad("keep", "Keep", "Make this sound home: Back returns here", keep); - pad("back", "Back", "Glide back to the last sound you kept", back); - pad("offer", "Offer", "Grow a variant from here into B", () => requestOffer()); + // Keep and Back settle a sound that has moved from home: they sit in the + // head's moved bar (`paintMoved`), shown exactly when there is something + // to keep or to go back from. ↵ and ⇧⌫ (ADR-018). + movedBar.append(el("span", "pf-moved-w mono", "moved")); + pad("keep", "Keep", "Make this sound home: Back returns here · ↵", keep, null, { parent: movedBar, cls: "pf-mv pf-keep", kbd: "↵", keys: "Enter" }); + pad("back", "Back", "Glide back to the last sound you kept · ⇧⌫", back, null, { parent: movedBar, cls: "pf-mv pf-back", kbd: "⇧⌫", keys: "Shift+Backspace" }); + // The pad row: Wander (built with the deck), then the model's three acts + // on B and a pass. The printed keys are ADR-018's. + pad("offer", "Offer", "Grow a variant from here into B · N", () => requestOffer(), null, { kbd: "N", keys: "N" }); // The one gesture no other instrument has; it reads as the primary. padEls.offer.classList.add("primary"); - pad("take", "Take", "Make the offer in B your sound", take); - pad( - "peek", - "Peek", - "Hold to hear the offer; let go to come back", - () => { - const live = host.live(); - state.peeking = true; - if (live && state.offer) live.bMix(1); - else host.note("Nothing offered yet. Press Offer first.", { urgent: true }); - }, - () => { - state.peeking = false; - const live = host.live(); - if (live && state.offer) live.bMix(state.blend); - }, - ); - // "Freeze", not "Hold": the dock's HOLD latches notes, and two buttons - // named the same thing on one screen doing different jobs is a trap. - pad("hold", "Freeze", "Freeze wander (tap Wander does the same)", toggleHold); - padEls.hold.setAttribute("aria-pressed", "false"); + pad("peek", "Peek", "Hold to hear the offer; let go to come back · hold B", peekOn, peekOff, { kbd: "B", keys: "B" }); + pad("take", "Take", "Make the offer in B your sound · ⇧↵", take, null, { kbd: "⇧↵", keys: "Shift+Enter" }); + // A pass on B without growing another (Next is a pass and an Offer): + // recorded as Next's pass is, once B has been heard, with its UNDO. + pad("pass", "Pass", "Pass on the offer in B without growing another", () => passOffer(), null, { cls: "pf-pad pf-pass" }); knobs.forEach(paintKnob); renderOffer(); renderStatus(); @@ -3995,6 +4339,7 @@ export function createPerform(host) { // hears a blend it cannot see. The offer itself stays for coming back. hide() { state.visible = false; + endPeekKey(); closePalette(false); const live = host.live(); if (live && state.offer) live.bMix(0); @@ -4014,7 +4359,7 @@ export function createPerform(host) { patchChanged, // The live patch was renamed (auto-names follow the pool). relabel() { - nameEl.textContent = host.label(); + renderHeadWords(); }, onWorker, // For MIDI and the keyboard: set a named control (0..5), Blend (6) or @@ -4053,7 +4398,9 @@ export function createPerform(host) { return k.spec.kind === "named" ? (k.value + 1) / 2 : k.value; }, pad(key) { - ({ keep, back, offer: () => requestOffer(), take, hold: toggleHold })[key]?.(); + ({ keep, back, offer: () => requestOffer(), take, pass: () => state.offer && passOffer(), hold: toggleHold })[key]?.(); }, + // Has the sound left home (the moved bar)? + moved: () => movedFromHome(), }; } diff --git a/apps/web/style.css b/apps/web/style.css index 1832a519..dc61229e 100644 --- a/apps/web/style.css +++ b/apps/web/style.css @@ -1996,35 +1996,84 @@ body.model-view .bank-item:not(:hover):not(.rating) .bi-acts { visibility: hidde .rack-exit, .rack-exit * { pointer-events: none; } /* ---- EVOLVE view ---- */ +/* To the specimen (Plan-008 C1, `z-desk-evolve.png`): the head (cap, the + question, the pips and the teaching line) with a small TASTE map at its + right; two cards, each its sound's face in a well, then its name, family + and blurb and PLAY and PICK; the foot (ANOTHER PAIR and the deal rule, + the forecast, EVOLVE POOL and what each generation did). */ +#view-evolve { padding: var(--s5) var(--s5) var(--s4); gap: var(--s4); } +.ev-head { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s5); } +.ev-head-words { min-width: 0; display: grid; gap: var(--s2); } +#view-evolve .ev-title { margin: 0; font-size: clamp(var(--t-title), 3.4vw, var(--t-display)); } /* (1,1,0): over .lv-title's display size, set further down */ +.duel-foot .hw-btn { white-space: nowrap; } +@media (max-width: 1180px) { + #view-evolve { padding: var(--s4) var(--s3) var(--s3); gap: var(--s3); } + #view-evolve .ev-title { font-size: var(--t-title); } + .ev-map { width: 200px; height: 110px; } + /* Each card's foot: the name on its line, PLAY and PICK under it, so the + two cards keep one height whatever their names. */ + .duel-foot { flex-direction: column; align-items: stretch; } +} +.ev-map { + flex: none; + position: relative; + width: 260px; + height: 140px; + padding: 0; + border: 1px solid var(--hairline); + border-radius: var(--r2); + background: radial-gradient(120% 90% at 50% 60%, var(--panel), var(--bezel) 70%); + cursor: pointer; +} +.ev-map canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.ev-map:hover { border-color: var(--silk-mute); } +.ev-map:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } +.ev-map-cap { position: absolute; left: var(--s2); bottom: var(--s1); font-family: var(--font-silk); font-size: var(--t-label); font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--silk-dim); } +.ev-map-cap kbd { font-family: var(--font-mono); letter-spacing: 0; text-transform: none; padding: 0 var(--s1); border: 1px solid var(--hairline); border-radius: 3px; } .duel-row { flex: 1; min-height: 0; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: stretch; gap: var(--s3); } +.duel-or { align-self: center; font-family: var(--font-silk); font-size: var(--t-label); letter-spacing: 0.16em; text-transform: uppercase; color: var(--silk-dim); } .duel-card { - display: flex; - flex-direction: column; - gap: var(--s2); - border: 1px solid var(--hairline); - border-radius: var(--r1); - padding: var(--s3); + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: var(--s3); min-height: 0; + min-width: 0; cursor: pointer; - background: - repeating-linear-gradient(115deg, var(--white-012) 0 1px, transparent 1px 3px), - linear-gradient(180deg, var(--panel-hi), var(--panel) 60%); } -.duel-card.live-sel { border-color: var(--phos-a-dim); box-shadow: 0 0 12px var(--phos-a-12); } +.duel-card.live-sel .duel-well { border-color: var(--phos-a-dim); box-shadow: 0 0 12px var(--phos-a-12); } .duel-hint { font-size: var(--t-label); } -.panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--s3); - flex-wrap: wrap; -} +/* The card's well: the sound's face, large, with its glow and its floor. */ +.duel-well { + position: relative; + min-height: 160px; + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: var(--r3); + background: radial-gradient(120% 90% at 50% 60%, var(--panel), var(--bezel) 62%); + box-shadow: inset 0 0 0 1px var(--black-60), inset 0 30px 60px -30px var(--black-75); +} +.duel-well > .face-slot { position: absolute; inset: var(--s5) var(--s4) var(--s3); } +.duel-well > .face-slot.hidden { display: none; } +.duel-side { position: absolute; left: var(--s3); top: var(--s3); font-family: var(--font-silk); font-size: var(--t-label); font-weight: 600; letter-spacing: 0.16em; color: var(--silk-dim); z-index: 2; } +/* While the sound renders, its sweep over the face; nothing once it has. */ +.duel-well > .scope { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; background: none; box-shadow: none; pointer-events: none; } +.duel-well > .mini-rack { position: absolute; inset: var(--s6) var(--s3) var(--s3); min-height: 0; } +.duel-well > .readout { position: absolute; left: var(--s3); right: var(--s3); bottom: var(--s2); z-index: 2; } +.duel-well > .style-badge { position: absolute; left: var(--s7); top: var(--s3); z-index: 2; } +.duel-corner { position: absolute; top: var(--s3); right: var(--s3); display: flex; gap: var(--s2); z-index: 3; } +.duel-corner .inspect-btn { background: var(--bezel); } +.duel-foot { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3); min-width: 0; } +.duel-who { min-width: 0; display: grid; gap: var(--s1); } +.duel-fam { font-family: var(--font-silk); font-size: var(--t-body); color: var(--silk-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.duel-cat { font-size: var(--t-label); font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--silk); margin-right: var(--s1); } +.duel-foot .duel-controls { flex: none; } .inspect-btn { font-family: var(--font-mono); font-size: var(--t-value); @@ -2036,6 +2085,7 @@ body.model-view .bank-item:not(:hover):not(.rating) .bi-acts { visibility: hidde cursor: pointer; } .inspect-btn:hover { color: var(--phos-a); border-color: var(--phos-a-dim); } +.inspect-btn kbd { margin-left: var(--s1); padding: 0 var(--s1); border: 1px solid var(--hairline); border-radius: 3px; } .scope, .crt { display: block; @@ -2058,26 +2108,35 @@ body.model-view .bank-item:not(:hover):not(.rating) .bi-acts { visibility: hidde word-break: break-all; } .duel-controls { display: flex; gap: var(--s2); } -.duel-mid { - flex: 0 0 auto; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: var(--s5); - padding: var(--s2) var(--s3); - border: 1px solid var(--hairline); - border-radius: var(--r1); - background: - repeating-linear-gradient(115deg, var(--white-012) 0 1px, transparent 1px 3px), - linear-gradient(180deg, var(--panel-hi), var(--panel) 60%); - transition: border-color var(--d-state) ease, box-shadow var(--d-state) ease; -} -/* "● it just learned": the refit has landed. The glow is the arrival, so it - belongs to `fitted`, not to the fit being sent (`.learning`, below). */ -.duel-mid.learned { - border-color: var(--phos-b); - box-shadow: 0 0 24px var(--phos-b-22), inset 0 0 18px var(--phos-b-07); +/* The teaching meter, under the question: no box, the pips and the line. */ +.duel-mid { flex: none; display: block; transition: color var(--d-state) ease; } +.duel-mid.learned .teach-copy { text-shadow: 0 0 12px var(--phos-b-35); } +/* The foot: ANOTHER PAIR, the deal rule and the forecast; EVOLVE POOL and + what each generation did. */ +.ev-foot { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s4); } +.ev-foot .duel-deal { flex-wrap: wrap; min-width: 0; } +.ev-foot .duel-pred { text-align: left; } +.ev-evolve { display: grid; justify-items: end; gap: var(--s1); } +.ev-lineage-btn { + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--silk-dim); + background: none; + border: 0; + padding: var(--s1); + cursor: pointer; } +.ev-lineage-btn::before { content: "›"; display: inline-block; margin-right: var(--s1); transition: transform var(--d-state) var(--e-settle); } +.ev-lineage-btn[aria-expanded="true"]::before { transform: rotate(90deg); } +.ev-lineage-btn:hover, .ev-lineage-btn[aria-expanded="true"] { color: var(--silk); } +.ev-lineage-btn:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } +/* EVOLVE POOL before the model has learned from your picks: dashed amber, + a guess (ADR-012), still pressable. */ +#evolve-btn.untaught { background: transparent; border: 1px dashed var(--phos-b-deep); box-shadow: none; } +@media (prefers-reduced-motion: reduce) { .ev-lineage-btn::before { transition: none; } } .prompt { text-align: center; font-family: var(--font-voice); @@ -2137,13 +2196,23 @@ body.model-view .bank-item:not(:hover):not(.rating) .bi-acts { visibility: hidde .hw-btn:disabled { opacity: 0.45; cursor: default; } .lineage-strip { - flex: 0 0 auto; - border-top: 1px solid var(--hairline); - padding-top: var(--s2); + position: absolute; + left: var(--s5); + right: var(--s5); + bottom: calc(var(--s7) + var(--s5)); + z-index: 6; display: flex; flex-direction: column; gap: var(--s1); + padding: var(--s3); + background: var(--surface-pop); + border: 1px solid var(--hairline); + border-radius: var(--r2); + box-shadow: var(--shadow-pop); } +.lineage-strip.hidden { display: none; } +.lineage-strip .panel-label { display: flex; align-items: center; gap: var(--s2); } +.lineage-x { margin-left: auto; } .lineage-body { display: flex; gap: var(--s4); align-items: stretch; } #lineage-spark { flex: 0 0 240px; @@ -2165,7 +2234,7 @@ body.model-view .bank-item:not(:hover):not(.rating) .bi-acts { visibility: hidde @media (hover: hover) { .tab:not(.active):hover { color: var(--silk); border-color: var(--silk-mute); } - .duel-card:not(.live-sel):hover { border-color: var(--silk-mute); } + .duel-card:not(.live-sel):hover .duel-well { border-color: var(--silk-mute); } } /* ---- TASTE and LEARNING: a level's head (taste.js) ---- @@ -4142,12 +4211,10 @@ input, textarea, .bi-rename { the guess hangs under the card's head, over its figure. */ .style-badge { visibility: hidden; opacity: 0; transition: opacity var(--d-state), visibility var(--d-state); } body.model-view .style-badge { visibility: visible; opacity: 1; } -.duel-card .panel-head { position: relative; } .duel-guess { position: absolute; - top: 100%; - right: 0; - margin-top: var(--s1); + top: calc(var(--s3) + 30px); + right: var(--s3); padding: 2px var(--s2); border: 1px solid var(--phos-b-deep); border-radius: 999px; @@ -5698,59 +5765,296 @@ html.handheld .handheld-gate { } /* ===================== PERFORM ===================== */ -/* The sound under your hands: up to eight named controls from a palette of - eighteen, Blend, a wander dial and six pads, on one faceplate. Built by - perform.js; nothing here opens a modal. */ +/* To the specimen (Plan-008 C1, `a-perform-doors.png`): the sound on the + left (its family, its name, its blurb, and the well holding its face), + what you turn on the right (CONTROLS, the knobs, the hood strip and the + pad row). Built by perform.js; nothing here opens a modal. */ .view.perform { - align-items: center; - justify-content: flex-start; overflow-y: auto; - gap: var(--s4); padding: var(--s5) var(--s5) var(--s4); } -.pf-head { - width: min(1040px, 100%); +.pf-grid { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--s6); +} +.pf-left { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--s4); + min-width: 0; + min-height: 0; +} +.pf-right { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + justify-content: safe center; gap: var(--s4); + min-width: 0; + min-height: 0; +} +/* The head: the family and "in hand" (with the moved bar at its end), the + name at the display size with share, and the blurb. Each line keeps its + height, so the well under it never moves from one sound to the next. */ +.pf-head { display: grid; gap: var(--s2); min-width: 0; } +.pf-cap-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s3); min-height: 30px; } +.pf-cap { + white-space: nowrap; + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--silk-dim); } -.pf-head .pf-title { margin-right: auto; } /* the held face sits before it */ +.pf-title { display: flex; align-items: center; gap: var(--s3); min-width: 0; } .pf-name { + min-width: 0; font-family: var(--font-silk); - font-size: 26px; /* token-exempt: PERFORM's held name. The specimen sets it as the level's display (52px) in a layout with room for it, which PERFORM's rebuild makes (Plan-005 task 5); at 52px here it pushes how it works under the keybed */ - font-weight: 500; - letter-spacing: 0.02em; + /* The level's display size, as the specimen sets it, down to the title's + where the column is narrow (a 1000 px window): one line, the whole name + in its title when a long one is cut. */ + font-size: clamp(var(--t-title), 3.5vw, var(--t-display)); + font-weight: 400; + line-height: 1.08; + letter-spacing: -0.01em; color: var(--silk); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.pf-status { font-size: var(--t-body); color: var(--silk-dim); margin-top: 2px; } -.pf-scope { - width: 360px; - max-width: 40vw; - height: 72px; - background: var(--bezel); +.pf-share { + flex: none; + width: 32px; + height: 32px; + padding: 0; + border-radius: 50%; + display: grid; + place-items: center; +} +.pf-share svg, +.pf-stage-btn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.pf-blurb { + min-height: 1.45em; + font-family: var(--font-silk); + font-size: var(--t-body); + line-height: 1.45; + color: var(--silk-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* Moved: the sound has left home (perform.js `movedFromHome`), and KEEP or + BACK settle it. In a slot the cap row always keeps: shown or hidden in + place, nothing moves. */ +.pf-moved { margin-left: auto; display: flex; align-items: center; gap: var(--s2); visibility: hidden; } +.pf-moved.on { visibility: visible; } +.pf-moved-w { font-size: var(--t-value); color: var(--silk-dim); } +.pf-mv { + display: inline-flex; + align-items: center; + gap: var(--s2); + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--silk); + background: var(--panel-hi); border: 1px solid var(--hairline); border-radius: var(--r1); + padding: var(--s1) var(--s2); + cursor: pointer; +} +.pf-mv:hover { border-color: var(--silk-dim); } +.pf-mv:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } +.pf-mv.flash { animation: pf-flash var(--d-move) ease-out; } +/* A printed key, drawn from `data-key` so a pad's words stay its name. */ +.pf-mv[data-key]::after, +.pf-pad[data-key]::before, +.pf-kbd { + font-family: var(--font-mono); + font-size: var(--t-label); + font-weight: 400; + letter-spacing: 0; + text-transform: none; + line-height: 1.3; + padding: 0 var(--s1); + border: 1px solid var(--hairline); + border-radius: 3px; + color: var(--silk-dim); +} +.pf-mv[data-key]::after { content: attr(data-key); } +@media (pointer: coarse) { + .pf-mv[data-key]::after, .pf-pad[data-key]::before, .pf-kbd { display: none; } + /* Under a finger, KEEP and BACK are as tall as the pads on a short screen. */ + .pf-mv { min-height: 44px; padding-inline: var(--s4); } +} + +/* The well: the sound's face, large, with its glow and the floor's + reflection (main.js FACE_OPTS, as stage mode draws it), and B beside it + while it holds an offer, in the model's amber. Its modes (perform.js + `setWellMode`): the face, the XY pad, How it works. */ +.pf-well { + position: relative; + min-height: 280px; + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: var(--r3); + background: radial-gradient(120% 90% at 50% 60%, var(--panel), var(--bezel) 62%); + box-shadow: inset 0 0 0 1px var(--black-60), inset 0 30px 60px -30px var(--black-75); +} +.pf-faces { + position: absolute; + inset: var(--s5) var(--s4) var(--s4); + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--s4); + cursor: pointer; +} +/* B holds an offer (or one is growing): the two faces side by side, their + floors on one line (both drawn on the same floor, at one scale). */ +.pf-well.offered .pf-faces { grid-template-columns: minmax(0, 1fr) minmax(0, 0.84fr); bottom: calc(var(--s7) + var(--s2)); } +.pf-faces > .pf-face, +.pf-offer > .pf-face { position: relative; width: 100%; height: 100%; min-width: 0; } +.face-slot.face-well .face, +.face-slot.face-wellb .face, +.face-slot.face-evolve .face { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.pf-well[data-mode="xy"] .pf-faces, +.pf-well[data-mode="how"] .pf-faces { opacity: 0.22; pointer-events: none; } +/* B: its label over its face, what changed and how to hear it under the + floor. Placed on the face's own floor line (FACE_OPTS: B's vessel stands + on the floor at 79% of its picture, 46% tall). */ +.pf-offer { position: relative; min-width: 0; transform-origin: 0 0; } +.pf-offer.empty { display: none; } +.pf-offer.moving { pointer-events: none; } +.pf-offer-label, +.pf-ghost-label { + position: absolute; + left: 0; + right: 0; + top: calc(33% - var(--s5)); + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + text-align: center; + color: var(--phos-b); } +.pf-offer-body, +.pf-ghost-body { + position: absolute; + left: 0; + right: 0; + top: 82%; + display: grid; + gap: 2px; + font-family: var(--font-mono); + font-size: var(--t-value); + line-height: 1.35; + text-align: center; + color: var(--phos-b-dim); +} +/* What changed, cut at two lines (the whole is B's title); where it grew + and how to hear it, whole. */ +.pf-offer-line, +.pf-ghost-line { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} +.pf-offer-how, .pf-ghost-how { color: var(--silk-dim); } +@media (max-width: 1180px) { + .pf-offer-body, .pf-ghost-body { top: 80%; } + .pf-offer-line, .pf-ghost-line { -webkit-line-clamp: 1; } +} +/* Growing, or a word with no offer: said where B's face will be. */ +.pf-offer:not(.ready) .pf-offer-body { top: 50%; transform: translateY(-50%); color: var(--phos-b); } +.pf-offer[data-moment="taken"] .pf-offer-label { color: var(--phos-a); } +@media (prefers-reduced-motion: reduce) { + .pf-offer[data-moment="taken"] { background: var(--phos-a-24); } +} +/* Blend: under the two faces, from the sound in hand to B, while B holds an + offer. */ +.pf-blend-slot { position: absolute; left: 50%; bottom: var(--s3); transform: translateX(-50%); width: min(360px, 80%); display: none; z-index: 2; } +.pf-blend-slot.on { display: block; } +.pf-well:not([data-mode="face"]) .pf-blend-slot { display: none; } +.pf-blend { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "l in r" "v v v"; + align-items: center; + gap: 2px var(--s2); + font-family: var(--font-silk); + font-size: var(--t-label); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--silk-dim); +} +.pf-blend-l { grid-area: l; color: var(--phos-a-dim); } +.pf-blend-r { grid-area: r; color: var(--phos-b); } +.pf-blend-in { grid-area: in; width: 100%; accent-color: var(--phos-b); } +.pf-blend-in:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } +.pf-blend-v { grid-area: v; text-align: center; letter-spacing: 0; text-transform: none; font-size: var(--t-value); color: var(--phos-b-dim); } +/* The live trace, small in the well's top left (the corner the buttons + leave free): clear of B and of Blend, so it stays while you compare. The + XY pad and How it works cover the well, and it gives way to them. */ +.pf-scope { position: absolute; left: var(--s3); top: var(--s3); width: 120px; height: 30px; opacity: 0.55; pointer-events: none; } +.pf-well:not([data-mode="face"]) .pf-scope { display: none; } +/* The well's corner: XY and stage mode. */ +.pf-corner { position: absolute; top: var(--s3); right: var(--s3); display: flex; gap: var(--s2); z-index: 3; } +.pf-corner .util-btn { display: inline-flex; align-items: center; gap: var(--s1); } +.pf-xy-btn[aria-pressed="true"] { color: var(--phos-a); border-color: var(--phos-a-deep); } +/* In booth mode the attract band lies over the top of the well. */ +.pf-marquee { position: absolute; left: var(--s3); right: var(--s3); top: var(--s3); display: grid; justify-items: center; pointer-events: none; z-index: 4; } + +/* CONTROLS' head: the cap, then Arrange and How it works, as disclosures. */ +.pf-ctlhead { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); } +.pf-ctlcap { + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--silk); +} +.pf-actions { display: flex; align-items: center; gap: var(--s3); } +.pf-disclose { + font-family: var(--font-silk); + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--silk-dim); + background: none; + border: 0; + padding: var(--s1); + cursor: pointer; +} +.pf-disclose::before { content: "›"; display: inline-block; margin-right: var(--s1); transition: transform var(--d-state) var(--e-settle); } +.pf-disclose[aria-expanded="true"]::before { transform: rotate(90deg); } +.pf-disclose:hover, .pf-disclose[aria-expanded="true"] { color: var(--silk); } +.pf-disclose:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } +.pf-status { min-height: 1.4em; font-size: var(--t-value); color: var(--silk-dim); } + .pf-deck { - width: min(1040px, 100%); display: grid; - /* The panel's controls, then Blend and Wander: eight columns, or one more - for each control past six (perform.js buildDeck sets the count). */ - grid-template-columns: repeat(var(--pf-cols, 8), minmax(0, 1fr)); - gap: var(--s3); - padding: var(--s4) var(--s4) var(--s3); - background: var(--surface-pop); - border: 1px solid var(--hairline); - border-radius: var(--r1); - box-shadow: var(--lift), var(--cast); + /* The panel's controls: three columns hold the six, as the specimen sets + them, four a longer panel (perform.js buildDeck sets the count). */ + grid-template-columns: repeat(var(--pf-cols, 3), minmax(0, 1fr)); + gap: var(--s3) var(--s2); } .pf-knob { + position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; - padding: var(--s2) 2px; + padding: var(--s1) 2px; border-radius: var(--r1); cursor: ns-resize; touch-action: none; @@ -5758,7 +6062,7 @@ html.handheld .handheld-gate { outline: none; } .pf-knob:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } -.pf-knob svg { width: 100%; max-width: 96px; height: auto; overflow: visible; } +.pf-knob svg { width: 100%; max-width: 84px; height: auto; overflow: visible; } /* The travel, as its two halves (perform.js makeKnob). Open: the way the control turns. Closed: a dotted hairline, with a stop at 12 o'clock. */ .pf-k-half { fill: none; stroke: var(--silk-mute); stroke-width: 3; } @@ -5787,10 +6091,11 @@ html.handheld .handheld-gate { .pf-k-ptr { stroke: var(--silk); stroke-width: 3; stroke-linecap: round; } .pf-k-where { fill: var(--phos-b); } .pf-k-name { + margin-top: var(--s1); font-family: var(--font-silk); - font-size: 17px; /* token-exempt: the specimen sets a control's name as a label and a pad as a raised button; PERFORM's rebuild does both (Plan-005 task 5), and at the title size the names crowd their columns */ - font-weight: 500; - letter-spacing: 0.06em; + font-size: var(--t-label); + font-weight: 600; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--silk); } @@ -5803,9 +6108,7 @@ html.handheld .handheld-gate { answer to "what does this turn?", and a caption that ends in an ellipsis reads as broken. It wraps instead, and the block keeps three lines' height, so the row does not jump when a caption changes length - (Wander's does, every few seconds): at 11 px in a 1000 px window the - longest states ("turns toward restless only", "staying: nothing better - nearby") take three. */ + (Wander's does, every few seconds). */ min-height: 3.9em; line-height: 1.3; text-align: center; @@ -5814,13 +6117,21 @@ html.handheld .handheld-gate { text-wrap: balance; overflow-wrap: break-word; } -/* Under 1280 px the eight columns give their gaps to the captions: at 1000 - px a column's 75 px held twelve characters a line, and three lines of - twelve could not hold every caption. */ -@media (max-width: 1279px) { - .pf-deck { gap: var(--s3) var(--s1); padding-inline: var(--s2); } - .pf-knob { padding-inline: 0; } +/* What velocity plays (Arrange › Velocity plays): a tick over the ring's + corner, so no word in the cell moves for it. */ +.pf-k-vel { + position: absolute; + top: var(--s1); + left: var(--s1); + display: none; + font-size: var(--t-label); + color: var(--phos-a-dim); + border: 1px solid var(--phos-a-deep); + border-radius: 3px; + padding: 0 3px; /* token-exempt: a tag's inset under the 4px step */ + pointer-events: none; } +.pf-knob.vel .pf-k-vel { display: block; } /* Unreachable by this patch's knobs: amber, and the gesture asks for an offer. */ .pf-knob.search .pf-k-arc { stroke: var(--phos-b); } .pf-knob.search .pf-k-half { stroke: var(--phos-b-deep); stroke-dasharray: 3 5; } @@ -5828,39 +6139,51 @@ html.handheld .handheld-gate { .pf-knob.search .pf-k-sub { color: var(--phos-b-dim); } .pf-knob.unwired .pf-k-ptr { stroke: var(--silk-mute); } .pf-knob.unwired .pf-k-arc { stroke: var(--silk-mute); } -.pf-knob.held .pf-k-sub { color: var(--phos-b); } /* Listening: not measured yet (no wiring, or a control a Take carried over with nothing to turn). The ring stays neutral and quiet, never the dashed amber of "can't"; only its waiting sign (.pf-k-wait) is amber, and only while a measurement is out. */ .pf-knob.pending .pf-k-half { stroke-width: 1; opacity: 0.6; } .pf-knob.pending .pf-k-name, .pf-knob.pending .pf-k-sub { color: var(--silk-dim); } + +/* What velocity plays, a row of the Arrange panel. */ .pf-touch { - width: min(1040px, 100%); display: flex; + flex-wrap: wrap; align-items: center; - gap: var(--s3); - font-size: var(--t-body); + gap: var(--s2) var(--s3); + padding: 0 var(--s2) 0 calc(var(--s3) + var(--s2) + var(--s2)); + font-size: var(--t-value); color: var(--silk-dim); } -.pf-touch-l { font-family: var(--font-silk); text-transform: uppercase; letter-spacing: 0.1em; font-size: var(--t-label); color: var(--silk); } -.pf-touch select { background: var(--bezel); color: var(--silk); border: 1px solid var(--hairline); border-radius: 3px; font: inherit; padding: 2px var(--s1); } +.pf-touch-l, .pf-touch-d { font-family: var(--font-silk); text-transform: uppercase; letter-spacing: 0.1em; font-size: var(--t-label); color: var(--silk-dim); } +.pf-touch-l { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } /* the section's heading says it on screen */ +.pf-touch select { background: var(--bezel); color: var(--silk); border: 1px solid var(--hairline); border-radius: 3px; font: inherit; padding: 2px var(--s1); max-width: 100%; } .pf-touch input[type=range] { width: 120px; } -.pf-touch-n { color: var(--phos-a-dim); } +.pf-touch-n { flex-basis: 100%; color: var(--phos-a-dim); } + +/* The pad row: Wander, ringed in amber (the model's walk; a tap freezes + it), then OFFER, PEEK, TAKE and PASS. */ .pf-pads { - width: min(1040px, 100%); display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: var(--s3); + grid-template-columns: 160px minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 0.95fr); + align-items: center; + gap: var(--s2); } .pf-pad { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 2px var(--s2); font-family: var(--font-silk); - font-size: 17px; /* token-exempt: the specimen sets a control's name as a label and a pad as a raised button; PERFORM's rebuild does both (Plan-005 task 5), and at the title size the names crowd their columns */ + font-size: var(--t-label); font-weight: 600; - letter-spacing: 0.12em; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--silk); - min-height: 64px; + min-height: 52px; + padding: var(--s1) var(--s2); border-radius: var(--r2); border: 1px solid var(--black); background: linear-gradient(180deg, var(--btn-hi), var(--btn-lo)); @@ -5869,7 +6192,10 @@ html.handheld .handheld-gate { touch-action: none; transition: transform var(--d-press) ease, box-shadow var(--d-press) ease; } -.pf-pad:active { transform: translateY(2px); box-shadow: inset 0 1px 0 var(--white-06), 0 1px 0 var(--shadow-ink); } +/* The printed key after the name (`data-key`, drawn before it in the box's + order). */ +.pf-pad[data-key]::before { content: attr(data-key); order: 1; } +.pf-pad:active, .pf-pad.down { transform: translateY(2px); box-shadow: inset 0 1px 0 var(--white-06), 0 1px 0 var(--shadow-ink); } @media (hover: hover) { .pf-pad:hover:not(:disabled) { background: linear-gradient(180deg, var(--btn-hover-hi), var(--btn-hover-lo)); } .pf-knob:not(.search):not(.pending):hover .pf-k-half:not(.closed) { stroke: var(--silk-dim); } @@ -5877,16 +6203,27 @@ html.handheld .handheld-gate { .pf-pad:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; } .pf-pad.lit { color: var(--phos-b); box-shadow: var(--lift), 0 0 12px var(--phos-b-30), 0 3px 0 var(--shadow-ink); } .pf-pad.flash { animation: pf-flash var(--d-move) ease-out; } -.pf-pad.primary { color: var(--phos-a); border-color: var(--phos-a-deep); } +/* OFFER is the model's act, and the one gesture no other instrument has: + amber, filled, as the specimen sets it. */ +.pf-pad.primary { + color: var(--evolve-ink); + border-color: var(--phos-b); + background: linear-gradient(180deg, var(--evolve-hi), var(--evolve-lo)); + box-shadow: var(--lift), 0 0 24px -6px var(--phos-b-60), 0 3px 0 var(--shadow-ink); +} +.pf-pad.primary[data-key]::before { color: var(--evolve-ink); border-color: var(--evolve-edge); } +@media (hover: hover) { + .pf-pad.primary:hover:not(:disabled) { background: linear-gradient(180deg, var(--evolve-hi), var(--evolve-hi)); } +} +.pf-pad.pf-pass { background: none; border-color: transparent; box-shadow: none; color: var(--silk-dim); } /* A pad with nothing to act on is *waiting*, and says for what — at 0.38 it read as broken. Dashed, flat and legible, with the reason underneath. */ .pf-pad:disabled { - opacity: 0.62; cursor: default; transform: none; box-shadow: none; - border: 1px dashed var(--hairline); - background: linear-gradient(180deg, var(--panel-hi), var(--pad-off-lo)); + border: 1px dashed var(--plate-edge); + background: transparent; color: var(--silk-dim); } /* …and a pad whose press does more than its name says (Offer, while B holds @@ -5895,53 +6232,57 @@ html.handheld .handheld-gate { .pf-pad:disabled[data-wait]:not([data-wait=""])::after { content: attr(data-wait); } .pf-pad:disabled[data-wait]:not([data-wait=""])::after, .pf-pad[data-sub]:not([data-sub=""])::after { - display: block; - margin-top: 3px; + order: 2; + flex-basis: 100%; font-family: var(--font-mono); - font-size: var(--t-value); + font-size: var(--t-label); font-weight: 400; - letter-spacing: 0.02em; + letter-spacing: 0; text-transform: none; } @keyframes pf-flash { from { color: var(--phos-a); box-shadow: 0 0 18px var(--phos-a-50); } } -.pf-offer { - width: min(1040px, 100%); - display: flex; +/* Wander in the pad row: its dial, and beside it its name and the word for + what it is doing (its ends, still and roam, are its tooltip's and its + ticks'). The word keeps two lines' height, so the row never jumps. */ +.pf-knob.pf-wander { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + grid-template-areas: "dial name" "dial line"; align-items: center; - gap: var(--s3); - padding: var(--s2) var(--s3); - border: 1px dashed var(--hairline); - border-radius: var(--r1); - color: var(--silk-dim); - font-family: var(--font-silk); - font-size: var(--t-body); -} -.pf-offer.ready { border-style: solid; border-color: var(--phos-b-deep); color: var(--silk); } -/* Two lines are kept whether or not an offer is there: B's strip used to grow - by a line when one landed, and push the XY pad down under a moving hand. */ -.pf-offer { min-height: calc(2 * 1.35em + 2 * var(--s2)); } -.pf-offer-body, -.pf-ghost-body { - line-height: 1.35; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; -} -.pf-offer-label, -.pf-ghost-label { - font-style: normal; - color: var(--phos-b); - border: 1px solid var(--phos-b-deep); - border-radius: 3px; - padding: 0 var(--s1); + justify-items: start; + column-gap: var(--s2); + row-gap: 0; + padding: var(--s1); } -.pf-hood { width: min(1040px, 100%); } -.pf-hood-h { font-family: var(--font-silk); text-transform: uppercase; letter-spacing: 0.1em; font-size: var(--t-label); color: var(--silk-dim); margin-bottom: var(--s2); } -.pf-hood-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s1) var(--s4); } +.pf-wander svg { grid-area: dial; max-width: 44px; } +.pf-wander .pf-k-name { grid-area: name; align-self: end; margin-top: 0; color: var(--phos-b); } +.pf-wander .pf-k-ends { display: none; } +.pf-wander .pf-k-line { grid-area: line; align-self: start; } +.pf-wander .pf-k-sub { text-align: left; min-height: 3.9em; } +.pf-wander .pf-k-half { stroke: var(--phos-b-deep); } +.pf-wander .pf-k-arc { stroke: var(--phos-b); } +/* Frozen: the ring lit and the word under it. */ +.pf-wander.held .pf-k-half { stroke: var(--phos-b); } +.pf-wander.held svg { filter: drop-shadow(0 0 6px var(--phos-b-45)); } +.pf-knob.held .pf-k-sub { color: var(--phos-b); } +/* Narrower, the row takes two lines: Wander across the first, the four pads + under it (the same order). */ +@media (max-width: 1360px) { + .pf-pads { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: var(--s1); } + .pf-pads .pf-wander { grid-column: 1 / -1; justify-self: stretch; grid-template-columns: 36px auto minmax(0, 1fr); grid-template-areas: "dial name line"; } + .pf-wander svg { max-width: 36px; } + .pf-wander .pf-k-name, .pf-wander .pf-k-line { align-self: center; } + .pf-wander .pf-k-sub { min-height: 2.6em; } +} + +/* Under the hood: the strip between the controls and the pads. */ +.pf-hood { flex: 0 1 auto; min-height: 5.5em; max-height: 9.5em; overflow-y: auto; overscroll-behavior: contain; } +.pf-ctlhead, .pf-status, .pf-deck, .pf-pads { flex: none; } +.pf-hood-h { font-family: var(--font-silk); text-transform: uppercase; letter-spacing: 0.1em; font-size: var(--t-label); color: var(--silk-dim); margin-bottom: var(--s1); } +.pf-hood-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 2px var(--s4); } .pf-hood-row { display: grid; - grid-template-columns: minmax(0, 11em) minmax(0, 1fr) 6.5em; + grid-template-columns: minmax(0, 1fr) 44px minmax(0, 5em); align-items: center; gap: var(--s2); background: none; @@ -5963,30 +6304,45 @@ html.handheld .handheld-gate { #rack-svg .hood-pulse { animation: hood-pulse 1.4s ease-out; } /* token-exempt: one attention beat, slower than any move */ @keyframes hood-pulse { 0%, 40% { filter: drop-shadow(0 0 6px var(--phos-a)); } 100% { filter: none; } } @media (prefers-reduced-motion: reduce) { #rack-svg .hood-pulse { animation: none; outline: 2px solid var(--phos-a); } } -@media (max-width: 900px) { .pf-hood-grid { grid-template-columns: minmax(0, 1fr); } } -.pf-why { width: min(1040px, 100%); } -.pf-why-body { max-width: 72ch; font-family: var(--font-silk); font-size: var(--t-body); color: var(--silk-dim); line-height: 1.5; } + +/* How it works: a mode of the well, its words over the dimmed face. */ +.pf-why-body { + position: absolute; + inset: 0; + z-index: 2; + overflow-y: auto; + overscroll-behavior: contain; + /* Clear of the well's corner (XY and stage mode), on a veil over the + dimmed face so the words read. */ + padding: calc(var(--s7) + var(--s2)) var(--s5) var(--s4); + background: var(--scrim-82); + font-family: var(--font-silk); + font-size: var(--t-body); + color: var(--silk-dim); + line-height: 1.5; +} +.pf-why-body.hidden { display: none; } .pf-why-body b { color: var(--silk); font-weight: 600; } -/* Under 1180 px, with the levels at the stage's edge, eight columns are - under the 88 px a caption needs for three lines at the widest renderer's - advance (77 px at 1000): the deck takes two rows of four, as the - prototype's controls do in two rows of three (text_fits.spec.js). */ +.pf-why-x { position: absolute; top: var(--s3); left: var(--s3); } + +/* Under 1180 px the two columns narrow: smaller dials, the pads' sub-words + and names in one size, Wander's dial smaller. */ @media (max-width: 1180px) { - .pf-deck { grid-template-columns: repeat(4, minmax(0, 1fr)); } -} -@media (max-width: 900px) { - .pf-pads { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .pf-scope { display: none; } + .pf-grid { gap: var(--s4); } + .pf-mv[data-key]::after { display: none; } + .view.perform { padding: var(--s4) var(--s3) var(--s3); } + .pf-knob svg { max-width: 68px; } + .pf-pad { letter-spacing: 0.1em; padding-inline: var(--s1); } + .pf-hood-grid { grid-template-columns: minmax(0, 1fr); } } @media (prefers-reduced-motion: reduce) { - .pf-pad.flash { animation: none; } + .pf-pad.flash, .pf-mv.flash { animation: none; } .pf-k-ghost.fade { animation: none; } .pf-knob.bump-lo .pf-k-turn, .pf-knob.bump-hi .pf-k-turn { animation: none; } + .pf-disclose::before { transition: none; } } /* ---------- PERFORM: the palette, How it works, the offer's moments ---------- */ -/* The header's two doors: arrange the panel, and stage mode. */ -.pf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); margin-left: auto; } /* A control being measured (perform.js paintKnob): "listening…" in its own box over the line under the control, #live-wait's sign. The line keeps its box, hidden, so nothing around it moves. */ @@ -6094,11 +6450,9 @@ html.handheld .handheld-gate { .pf-how-here { color: var(--phos-a-dim); } /* The offer's moments (perform.js growOffer, takeMotion, foldMotion). B - grows from the sound's name; a copy of it fills green and goes into the - name when taken, or folds back into it when passed. Under reduced motion - nothing moves, and B shows the taken state as a fill for a moment. */ -.pf-offer { transform-origin: 0 0; position: relative; } -.pf-offer.moving { pointer-events: none; } + grows from the sound's face in the well; a copy of it fills green and goes + into the face when taken, or folds back into it when passed. Under reduced + motion nothing moves, and B shows the taken state as a fill for a moment. */ .pf-offer-ghost { position: fixed; z-index: 30; @@ -6107,18 +6461,9 @@ html.handheld .handheld-gate { overflow: hidden; pointer-events: none; transform-origin: 0 0; - background: var(--rack); - /* B's look when it holds an offer (.pf-offer.ready), on a copy of it. */ - display: flex; - align-items: center; - gap: var(--s3); - padding: var(--s2) var(--s3); - border: 1px solid var(--phos-b-deep); - border-radius: var(--r1); - color: var(--silk); - font-family: var(--font-silk); - font-size: var(--t-body); + border-radius: var(--r2); } +.pf-offer-ghost > .pf-face { position: absolute; inset: 0; } .pf-ghost-fill { position: absolute; inset: 0; @@ -6126,10 +6471,6 @@ html.handheld .handheld-gate { transform-origin: 50% 100%; transform: scaleY(0); } -.pf-offer[data-moment="taken"] { border-style: solid; border-color: var(--phos-a-deep); } -@media (prefers-reduced-motion: reduce) { - .pf-offer[data-moment="taken"] { background: var(--phos-a-24); } -} /* Stage mode (⇧F): the sound under your hands, the whole screen. */ html.st-on { overflow: hidden; } @@ -6143,11 +6484,18 @@ html.st-on { overflow: hidden; } touch-action: manipulation; } .st-canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +/* To the specimen (`a-stage.png`): the wordmark and its lamp top left, × + top right, the name and "family · in hand" bottom left, the keys centred + along the foot. */ +.st-brand { position: absolute; left: var(--s6); top: var(--s6); display: flex; align-items: center; gap: var(--s2); color: var(--silk-dim); pointer-events: none; } +.st-brand .word { font-family: var(--font-silk); font-size: var(--t-label); font-weight: 500; line-height: 1; letter-spacing: 0.34em; margin-right: -0.34em; } +.st-brand .lamp { width: 7px; height: 7px; } .st-hud { position: absolute; left: var(--s6); bottom: var(--s6); display: grid; gap: var(--s2); pointer-events: none; } -.st-name { font-family: var(--font-silk); font-size: var(--t-title); font-weight: 500; letter-spacing: 0.145em; text-transform: uppercase; color: var(--silk); } +.st-name { font-family: var(--font-silk); font-size: var(--t-body); font-weight: 600; letter-spacing: 0.3em; text-transform: uppercase; color: var(--silk); } .st-cat { font-size: var(--t-value); color: var(--silk-dim); } .st-hint { position: absolute; left: 50%; bottom: var(--s6); transform: translateX(-50%); font-size: var(--t-value); color: var(--silk-dim); white-space: nowrap; pointer-events: none; } -.st-leave { position: absolute; right: var(--s5); top: var(--s5); } +.st-kbd { font-family: var(--font-mono); font-size: var(--t-label); color: var(--silk); padding: 0 var(--s1); border: 1px solid var(--plate-edge); border-radius: 3px; line-height: 1.3; } +.st-leave { position: absolute; right: var(--s5); top: var(--s5); width: 40px; height: 40px; padding: 0; border-radius: 50%; } /* A refusal said while the stage is on stays in sight: urgent toasts and the alarm are lifted over it. A receipt is not news on stage: it is hidden, and expires unseen behind it. A refusal's words are also said in the @@ -6159,6 +6507,7 @@ html.st-on .alarm { z-index: 97; } .st-tick { position: absolute; transform: translateY(-50%); font-size: var(--t-value); color: var(--silk-dim); pointer-events: none; } @media (max-width: 700px) { .st-hud { left: var(--s4); bottom: var(--s5); } + .st-brand { left: var(--s4); top: var(--s4); } .st-hint { bottom: calc(2 * var(--s7) + var(--s4)); white-space: normal; text-align: center; width: calc(100% - 2 * var(--s4)); } .st-leave { right: var(--s3); top: var(--s3); } } @@ -6239,29 +6588,23 @@ button.midi-ind:hover, button.midi-ind:focus-visible { border-color: var(--hairl measurement, so amber; a miss says so in words, not in another colour. */ .pf-offer-aim, .pf-ghost-aim { color: var(--phos-b-dim); font-style: normal; } -/* ---------- PERFORM: XY pad beside the hood ---------- */ -.pf-stage { - width: min(1040px, 100%); - display: grid; - grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); - gap: var(--s5); - align-items: start; -} -.pf-stage .pf-hood { width: auto; } -@media (max-width: 900px) { .pf-stage { grid-template-columns: 1fr; } } -.pf-xy { display: flex; flex-direction: column; gap: var(--s2); } -.pf-xy-head { display: flex; align-items: center; gap: var(--s2); color: var(--silk-dim); font-size: var(--t-label); } -.pf-xy-cap { font-family: var(--font-silk); letter-spacing: 0.1em; text-transform: uppercase; } +/* ---------- PERFORM: the XY pad, a mode of the well ---------- */ +/* Its axis pickers along the well's top edge (clear of the corner's two + buttons), the field filling the rest, the end words at its edges. */ +.pf-xy { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; gap: var(--s2); padding: var(--s3); } +.pf-xy.hidden { display: none; } +.pf-xy-head { display: flex; align-items: center; gap: var(--s2); min-height: 30px; padding-right: calc(2 * var(--s7) + var(--s5)); color: var(--silk-dim); font-size: var(--t-label); } .pf-xy-field { position: relative; - height: 190px; + flex: 1 1 auto; + min-height: 0; border: 1px solid var(--hairline); border-radius: var(--r2); background: linear-gradient(90deg, transparent calc(50% - 0.5px), var(--phos-a-12) 50%, transparent calc(50% + 0.5px)), linear-gradient(0deg, transparent calc(50% - 0.5px), var(--phos-a-12) 50%, transparent calc(50% + 0.5px)), radial-gradient(circle at 50% 50%, var(--phos-a-06), transparent 70%), - var(--rack-bed-lo); + var(--black-50); cursor: crosshair; touch-action: none; overflow: hidden; @@ -6328,18 +6671,12 @@ button.midi-ind:hover, button.midi-ind:focus-visible { border-color: var(--hairl .ng-title { font-family: var(--font-silk); letter-spacing: 0.12em; text-transform: uppercase; color: var(--phos-a); margin-bottom: var(--s3); } /* ---------- booth attract mode ---------- - The band sits in PERFORM's marquee row (under the title, where the first - steps go) rather than over the stage: attract is the product's pitch, and - the pitch is the knobs, pads, XY and hood moving — so the band covers none - of them, and booth mode keeps its row reserved so nothing moves when a - visitor takes over. The logotype follows the brand: silk Jost 500 on the + The band lies over the top of PERFORM's well (`.pf-marquee`) rather than + over the stage: attract is the product's pitch, and the pitch is the + knobs, pads and hood moving — so the band covers none of them, and + nothing moves when a visitor takes over. The logotype follows the brand: silk Jost 500 on the 0.145em ramp, no glow. The phosphor is the caption's, because the caption is the live reading — what the invisible hand is doing right now. */ -.pf-marquee { width: min(1040px, 100%); display: grid; align-items: center; } -.pf-marquee > * { grid-area: 1 / 1; } -.pf-marquee:not(:has(> :not(.hidden))) { display: none; } -html.booth .pf-marquee { display: grid; min-height: 46px; } -.pf-marquee:has(> .booth-attract:not(.hidden)) .pf-steps { visibility: hidden; } .booth-attract { justify-self: center; max-width: 100%; @@ -6387,23 +6724,48 @@ g.performed .knob-value { fill: var(--phos-b); } PERFORM is playing can be found across the whole rack at a glance. */ #rack-svg[data-illegible~="label"] g.performed .knob-body { stroke: var(--phos-b); stroke-width: 2.5; } -/* ---------- PERFORM first steps ---------- */ -.pf-steps { - width: min(1040px, 100%); - display: flex; - flex-wrap: wrap; - gap: var(--s2) var(--s4); - font-size: var(--t-label); - color: var(--silk-dim); +/* ---------- the guide pill (guide.js) ---------- + The first-visit steps, one at a time, bottom left of the stage (the + specimen's `.guide`): pips, the step, ×. PERFORM's steps today, so it + shows on PERFORM, and PERFORM keeps it a row of room under the well. */ +.guide { position: absolute; left: var(--s5); bottom: var(--s4); z-index: 6; max-width: calc((100% - var(--rail-w)) / 2 - var(--s5)); } +.guide.hidden, body:not([data-level="perform"]) .guide { display: none; } +body:has(#guide:not(.hidden)) .view.perform .pf-left { padding-bottom: var(--s7); } +.guide .next { + display: inline-flex; + align-items: center; + gap: var(--s3); + max-width: 100%; + height: 36px; + padding: 0 var(--s2) 0 var(--s4); + border: 1px solid var(--plate-edge); + border-radius: 999px; + background: var(--panel-hi); + box-shadow: var(--cast); + font-family: var(--font-silk); + font-size: var(--t-body); + color: var(--silk); +} +.guide .pips { display: flex; gap: var(--s1); } +.guide .pips i { width: 5px; height: 5px; border-radius: 50%; background: var(--plate-edge); } +.guide .pips i.done { background: var(--phos-a); } +.guide .pf-step { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.guide .pf-step.all { color: var(--phos-a); } +.guide .x { flex: none; width: 24px; height: 24px; display: grid; place-items: center; border: 0; border-radius: 50%; background: none; color: var(--silk-dim); cursor: pointer; } +.guide .x:hover { color: var(--silk); background: var(--plate-edge); } +.guide .x:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 1px; } +/* Under a finger, the pill and its × are a finger's size. */ +@media (pointer: coarse) { + .guide .next { height: 44px; } + .guide .x { width: 40px; height: 40px; } +} +@media (max-width: 1180px) { + .guide { left: var(--s3); bottom: var(--s3); max-width: calc((100% - var(--rail-w)) / 2 - var(--s3)); } + .guide .next { font-size: var(--t-value); gap: var(--s2); padding-left: var(--s3); } } -.pf-steps.hidden { display: none; } -.pf-step { white-space: nowrap; } -.pf-step.now { color: var(--phos-a); text-shadow: 0 0 8px var(--phos-a-45); } -.pf-step.done { color: var(--silk-dim); } -.pf-step.done.all { color: var(--phos-a); } -/* One voice per lesson: while PERFORM's first steps are showing, their step 1 - says what the keybed coach says, so the coach waits. */ -body:has(#view-perform:not(.hidden) .pf-steps:not(.hidden)) .coach { display: none; } +/* One voice per lesson: while the pill's step 1 says what the keybed coach + says, the coach waits. */ +body[data-level="perform"]:has(#guide:not(.hidden)) .coach { display: none; } /* A preset row whose load is on its way: said at once, where the eye is. A bank row whose bench open is on its way says the same, until the rack is @@ -6427,22 +6789,15 @@ body:has(#view-perform:not(.hidden) .pf-steps:not(.hidden)) .coach { display: no .pf-name.pending { opacity: 0.55; } /* ---------- PERFORM on a shorter screen ---------- - Everything PERFORM needs mid-phrase has to be on screen without scrolling. - At 1440×900 the XY pad ran under the keybed and at 1280×800 only its top - showed, so on a screen under ~960 px tall the deck tightens: smaller dials, - lower pads, a shorter XY field and less air between them. A 1920×1080 booth - display keeps the full size. */ -@media (max-height: 960px) { - .view.perform { gap: var(--s3); padding-top: var(--s4); } - .pf-knob svg { max-width: 80px; } - .pf-pad { min-height: 52px; } - .pf-xy-field { height: 150px; } -} + Everything PERFORM needs mid-phrase has to be on screen without scrolling: + on a screen under ~840 px tall the right column tightens (smaller dials, + lower pads, less air). */ @media (max-height: 840px) { - .view.perform { gap: var(--s2); padding-top: var(--s3); } - .pf-knob svg { max-width: 68px; } + .pf-right { gap: var(--s2); } + .pf-deck { row-gap: var(--s1); } + .pf-knob svg { max-width: 56px; } .pf-pad { min-height: 44px; } - .pf-xy-field { height: 118px; } + .pf-hood { min-height: 3.6em; max-height: 6.5em; } } /* ---------- Explain anything (explain.js, Plan-005 task 10) ---------- diff --git a/apps/web/taste.js b/apps/web/taste.js index cda91d21..9d145a67 100644 --- a/apps/web/taste.js +++ b/apps/web/taste.js @@ -1525,6 +1525,53 @@ export function createTaste(host) { }, /** Redraw with whatever views main now holds. */ draw: sync, + /** EVOLVE's small TASTE map (Plan-008 C1), drawn into `cv` at its CSS + * size: every sound in the pool where the engine's map puts it + * (`taste_map`), the model's liking a faint amber halo once it has + * fitted (`WasmEngine::belief`), and the pair on the table, `a` and `b`, + * ringed and lettered where they are on it. Still: it is redrawn when a + * views post, a pick's ratings or a new pair changes what it shows, and + * draws nothing before the engine has a map. */ + drawMini(cv, { a = null, b = null } = {}) { + const w = cv.clientWidth, h = cv.clientHeight; + if (!w || !h) return; + const ctx = sizeCanvas(cv, w, h); + ctx.clearRect(0, 0, w, h); + const v = host.views(); + const pts = (v && v.map && Array.isArray(v.map.points) ? v.map.points : []).filter((p) => p.id != null && !host.isCut(p.id)); + if (!pts.length) return; + const pos = geom.miniLayout(pts, w, h, 14); + const isFit = !!(v && v.styles); + const like = new Map(pts.map((p) => [p.id, p.utility])); + if (v.ratings && Array.isArray(v.ratings.ranked)) for (const row of v.ratings.ranked) like.set(row.id, row.mean); + for (const [id, q] of pos) { + if (isFit) { + const l = geom.liking(like.get(id) ?? 0); + const g = ctx.createRadialGradient(q.x, q.y, 0, q.x, q.y, 3 + l * 9); + g.addColorStop(0, inkAlpha(INK.amber, 0.08 + 0.3 * l)); + g.addColorStop(1, inkAlpha(INK.amber, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(q.x, q.y, 3 + l * 9, 0, Math.PI * 2); + ctx.fill(); + } + ctx.beginPath(); + ctx.arc(q.x, q.y, 1.6, 0, Math.PI * 2); + ctx.fillStyle = inkAlpha(INK.green, 0.6); + ctx.fill(); + } + for (const [id, letter] of [[a, "A"], [b, "B"]]) { + const q = id != null ? pos.get(id) : null; + if (!q) continue; + ctx.beginPath(); + ctx.arc(q.x, q.y, 5, 0, Math.PI * 2); + ctx.strokeStyle = inkAlpha(INK.silk, 0.9); + ctx.lineWidth = 1.2; + ctx.stroke(); + ctx.fillStyle = inkAlpha(INK.silk, 0.9); + text(ctx, letter, q.x, q.y - 9, "center"); + } + }, /** The model view went up or down (shell.js): TASTE's side of the * toggle while it is up. */ setModelView(on) { diff --git a/apps/web/tests/guide.test.mjs b/apps/web/tests/guide.test.mjs new file mode 100644 index 00000000..eecd9684 --- /dev/null +++ b/apps/web/tests/guide.test.mjs @@ -0,0 +1,35 @@ +// Unit tests for guide.js's storage: what the guide pill reads back, and the +// first steps' old key migrated into it. +// Run: node --test apps/web/tests +import test from "node:test"; +import assert from "node:assert/strict"; +import { readGuide, GUIDE_KEY, OLD_STEPS_KEY } from "../guide.js"; + +const store = (o) => (k) => (k in o ? o[k] : null); + +test("nothing stored is a fresh start, and nothing to migrate", () => { + assert.deepEqual(readGuide(store({})), { done: [], closed: false, migrated: false }); +}); + +test("the pill's own key is read as it was written", () => { + const got = readGuide(store({ [GUIDE_KEY]: JSON.stringify({ done: ["play", "turn"], closed: true }) })); + assert.deepEqual(got, { done: ["play", "turn"], closed: true, migrated: false }); +}); + +test("the first steps' old array is migrated, and the pill opens", () => { + const got = readGuide(store({ [OLD_STEPS_KEY]: JSON.stringify(["play", "offer"]) })); + assert.deepEqual(got, { done: ["play", "offer"], closed: false, migrated: true }); +}); + +test("the new key wins over the old one", () => { + const got = readGuide(store({ [GUIDE_KEY]: JSON.stringify({ done: ["turn"], closed: false }), [OLD_STEPS_KEY]: JSON.stringify(["play"]) })); + assert.deepEqual(got.done, ["turn"]); + assert.equal(got.migrated, false); +}); + +test("what cannot be read is a fresh start; ids are strings, each once", () => { + assert.deepEqual(readGuide(store({ [GUIDE_KEY]: "{" })).done, []); + assert.deepEqual(readGuide(store({ [GUIDE_KEY]: JSON.stringify({ done: ["play", 3, "play", null], closed: "yes" }) })), { done: ["play"], closed: false, migrated: false }); + assert.deepEqual(readGuide(store({ [OLD_STEPS_KEY]: "not json" })), { done: [], closed: false, migrated: true }); + assert.deepEqual(readGuide(() => { throw new Error("private window"); }), { done: [], closed: false, migrated: false }); +}); diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index 69a2dbb4..f19655e3 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -100,7 +100,10 @@ walk), `bank_kept.spec.js` (21 s on a 16-core M3 Max, not yet on CI: the warm start, its fit, a full pool, a keep as new and a preset), `evolve_truth.spec.js`'s generation with no farm (186 s), -`perform_next.spec.js` (78 s), `perform_truth.spec.js`'s drift re-check +`perform_next.spec.js` (78 s), `perform_layout.spec.js`'s Blend and PASS +and `pad_keys.spec.js`'s N, B and ⇧↵ (each grows two offers and answers +them: about a minute each on a 16-core M3 Max with its re-check and spare +first, not yet on CI), `perform_truth.spec.js`'s drift re-check (72 s), all five tests in `perform_instant.spec.js` (66 and 44 s; the held-engine reload test, which boots twice like them; the reload-at-once test, which boots three times: 14-20 s on a 16-core M3 Max, not yet on CI; @@ -129,7 +132,7 @@ refit and a ⚡ walk to raise the crew, then a capture), and measured, and a figure asked of each control: 31 s on a 16-core M3 Max, not yet on CI), and `patch_cables.spec.js`'s measurement nobody is waiting on (it waits for a measurement of a tree nothing has measured to finish: 10 to -35 s on a 16-core M3 Max, not yet on CI). Thirty-two tests, ~21 min in one worker; the other 87 took ~16.6 min +35 s on a 16-core M3 Max, not yet on CI). Thirty-four tests, ~21 min in one worker; the other 87 took ~16.6 min when last timed (at 80), and the fast tier's five runners take 2.6–3.9 min each. The next slowest (a shipped-wirings fetch that never answers, 38 s; the warm start's two, 36 and 32 s) stay fast. @@ -148,7 +151,12 @@ that matches nothing fails its leg (`--no-tests=fail`). `make test` and | `smoke.spec.js` | Clean boot, worklet registered, engine playable; the binary exports the walk surface, the `belief` call and the face calls `worker.js` calls | | `boot_agrees.spec.js` | The built wasm's `boot_probe` (the shipped seed's first 400 trees, a small pool and its first duels) equals what native `shipped::boot_probe` pins in `boot_probe.json`; opens no page, about 3 s under Node | | `failure_flows.spec.js` | Bad save, engine error, refused vote (and no ratings posted for it), profile import are contained | -| `first_run.spec.js` | The warm start keeps all 18 preferences; PERFORM's first steps tick off | +| `first_run.spec.js` | The warm start keeps all 18 preferences; PERFORM's first steps tick off in the guide pill | +| `guide_pill.spec.js` | The guide pill (Plan-008 C1) shows one step at a time, bottom left of the stage under PERFORM's well and on PERFORM only, ticks each off as it happens (a note, a turn, an offer), says what the loop was and goes; × stops it across a reload (`auracle-guide`); the first steps' old ticks (`auracle-perform-steps`) carry over and the old key goes | +| `perform_layout.spec.js` | PERFORM as the specimen's well and panel at 1000, 1280 and 1440 px: the sound left of what you turn, the pad row WANDER · OFFER · PEEK · TAKE · PASS above the keybed with nothing cut; XY is a mode of the well (the button or Esc puts the face back, focus to the button) and How it works the other; Freeze is a tap or Enter on Wander, said on Wander; the moved bar shows only when the sound has left home, without moving the name, and BACK and KEEP settle it; Blend shows only while B holds an offer, and PASS passes without growing another, recorded as Next's pass after its window, its UNDO bringing B back (`@slow`) | +| `perform_touch.spec.js` | On a tablet (a coarse pointer and touch) no pad shows a printed key, and every PERFORM function the layout moved is a tap away: Wander's freeze, XY in the well and its field, the moved bar's KEEP, HOW IT WORKS, ARRANGE's velocity row, stage mode's tap and ×; EVOLVE's ⇄ circuit and what each generation did | +| `pad_keys.spec.js` | ADR-018's keys: N offers and with B full passes and offers again, B held is PEEK held, ⇧↵ takes (`@slow`); ↵ keeps only with no control focused, ⇧⌫ goes back, and at home each says there is nothing to do; the keys yield to a text field and a modal, the note keys still play, and N in EVOLVE deals another pair | +| `evolve_cards.spec.js` | EVOLVE's cards (Plan-008 C1): each sound's face large in its card's well, ⇄ circuit swapping it for the patch and back, ↓ patch opening it in PATCH; the relabelled buttons keep their ids; the small map goes to TASTE; EVOLVE POOL is dashed and pressable before the first fit; what each generation did opens over the foot of the cards and Esc folds it | | `bank_row.spec.js` | A bank row's controls appear on approach and work | | `evolve_feedback.spec.js` | TAUGHT counts at once, pick toasts replace, the dealing rule, the sixth-pick redraw, every pick's reply carrying ratings that move per pick (ten seeds, ten may-replace), bank ▶ | | `patch_editing.spec.js` | The bench lane: edits in order, no lost edit, knobs survive redraws, receipts; KEEP AS NEW's blind card (Esc cancels), the one-shot *pick the edit*, a commit retiring the edits' receipts | @@ -165,7 +173,7 @@ that matches nothing fails its leg (`--no-tests=fail`). `make test` and | `bank_lineage.spec.js` | Pointing at EVOLVE POOL marks the seeds and what may be replaced from the engine's `ratings`, and during a generation its own seeds (posted with its progress, checked against each walk's `seed`) and `retiring` as *will be replaced*; no mark (the unheard dot, NEW, seed, may be replaced) moves or narrows a row's name, measured; the unheard dot survives a reload and clears when heard; Compare shows a child beside its seed, the diff and both ratings, and plays both while both exist; a refused child buds beside its seed and is gone, and EVOLVE POOL names each walk's outcome; a generation's children land in New with their seed and what changed, and Replaced lists only the names its end replaced; Compare lists every change and scrolls, and opens with c from the bank; while ⚡ walks, its seed and the sound its child would replace are marked, and that is what it replaces; a ⚡ child that replaced nothing leaves Replaced as it was; the name's place and each mark word's fit are measured at 1440 and 1080 px; GENERATIONS counts a generation from its first child with no pick since it opened; a sound saved mid-run loses *will be replaced* and the one that will go instead gains it | | `faces.spec.js` | A face lands on every row, EVOLVE card, PATCH's header and teach strip, PERFORM's sound in hand and its offer (the offer's its own), and the warm start's cards; a cut redraws the bank's faces against the bank as it is now; a row's name has the same x and width with and without its face, uncut, on a desktop and a phone; the sound's card downloads at 1200 × 630 with its face, its name and its patch inside (PNG and SVG); after a reload every row draws the same face | | `budgets.spec.js` | The response-time budget: the timing marks exist; a preset's controls live ≤ 1 s from its click; a warm-start pick's ≤ 1 s from *teach it*; pick → next pair ≤ 0.3 s; duel ▶ ≤ 0.15 s | -| `perform_controls.spec.js` | Half-closed controls stop at centre, XY axes, the status line | +| `perform_controls.spec.js` | Half-closed controls stop at centre, XY axes (in the well's XY mode), the status line | | `perform_open_early.spec.js` | With the engine's messages held: a Keep while a patch is still opening is refused and says why; the preset clicked last is the one opened; an open that cannot complete puts the voices back on the rack | | `perform_instant.spec.js` | With the shipped file blocked, the player's cache: a revisit wired ≤ 0.5 s, ≤ 1.5 s after a reload (from the cache, not measured again), and a preset opened before wired from the click with every message to the engine held; a preset opened, or a patch measured, just before a reload is remembered after it (both caches are written as the page is left); a spare offer lands at once; a kept wiring stamped by a build before the render namespace (a new quiver) plays at once, is re-measured, and is replaced | | `perform_next.spec.js` | A spare grows while B holds an offer, so NEXT is as fast as Offer; the pad reads NEXT · passes on B; a heard pass has UNDO (B back, nothing recorded) and counts after its window; an unheard pass says it was not counted | @@ -174,7 +182,7 @@ that matches nothing fails its leg (`--no-tests=fail`). `make test` and | `perform_teaches.spec.js` | An offer heard and answered is a pick; unheard, it is not | | `perform_offer_latency.spec.js` | With the page's own `later` work done (the shipped wiring's re-check, the page's spare) and then a very long spare offer growing, a pick (`perform_record`) is answered within max(2 s, twice a measured step) and a Keep says so within max(4 s, six steps), the spare still growing (the page's CPU is throttled 4x, which need not reach the engine worker, so the bounds rest on the measured step); leaving the patch (`retire`) stops the running walk, answered `retired`; an Offer pressed while the guess waits for its render crew (the crew's ports held back from the engine worker until the Offer is answered, so the guess is still out every run) is answered within max(2 s, three steps) | | `perform_palette.spec.js` | The palette places, hides and orders up to eight controls, and the panel comes back after a reload; a placed control is measured with the panel's set (asked in palette order), keyed by that set, and says *listening…* until it is; each knob wears its own control's wiring and an aimed offer names its control by palette index on a panel in another order; HOW IT WORKS lists every placed control and opens on the one last touched; a row's mark never moves its name | -| `perform_offer_moments.spec.js` | B grows from the sound's name, a taken B fills and goes into the name, a passed B folds back into it (each motion's keyframes against the page); an offer taken unheard becomes the sound and records no pick (no `perform_record`), and taken heard records one; a heard Take kept as new inside its eight-second window is recorded after the keep with an `asOf` below the kept id, so it does not judge the kept sound | +| `perform_offer_moments.spec.js` | B grows from the sound's face in the well, a taken B fills and goes into the face, a passed B folds back into it (each motion's keyframes against the page); an offer taken unheard becomes the sound and records no pick (no `perform_record`), and taken heard records one; a heard Take kept as new inside its eight-second window is recorded after the keep with an `asOf` below the kept id, so it does not judge the kept sound | | `perform_stage.spec.js` | ⇧F enters stage mode and ⇧F or Esc leaves; Space plays in it and it draws only while sound plays; F alone is still a note; ⇧F is stage mode in PERFORM only and the accented F in PATCH; Tab stays inside it and focus comes back on leave; a refusal said in it is in sight | | `explain.spec.js` | Each control on the panel opens its figure (by ?, by its chip, from the switcher and the arrow keys), asked of the engine for that control on the sound in hand, and a view change puts it away; every one of the palette's eighteen opens its figure (`@slow`); a control asked about before it is measured answers once it is, a search control too; a turn with an answer open asks at most twice; Space plays with an answer open and in the lesson, after a click on their buttons too, and NEXT takes Enter only; the lesson says why a render failed (injected), draws and plays nothing for it, says when the filter goes after a sound with no room, and asks nothing more after the sound itself fails; ? is the key map's once the pointer has left a control a mouse turned, and never opens over an answer; a long press on a touch screen opens the answer and a moving finger does not (CDP touch); with a bank of faces, BRIGHT's figure and the lesson draw the sound's face from the portrait's own; with no answer or lesson open, a pressed Offer grows with no explain request sent, and an answer put away cancels its waiting request (answered as `cancelled`); `AURACLE_CPU_THROTTLE=4` runs them at a quarter speed, the engine worker too (`perform_budget.js`); a figure's sentence is built from the worker's reply (BRIGHT's center, made and turned) and follows its control when turned; the lesson on filters renders the sound in hand, its cutoff's readout is the reply's and the filtered top sits lower, and another sound gives another lesson; under reduced motion a figure is drawn whole and holds, and with motion MOTION's runs; asking moves no control's or bank row's label | | `perform_aimed.spec.js` | A search control's offer is asked for aimed (control and way), B counts while it grows and then says how far it moved, in amber; the Offer pad's offer is not aimed | @@ -188,7 +196,7 @@ that matches nothing fails its leg (`--no-tests=fail`). `make test` and | `patch_cables.spec.js` | Each audio cable's light and level mark follow the levels `cable_levels` posts, one per cable, keyed `from>to` as the rack draws them; modulation cables carry neither; never more than one probe at the engine; a knob drag asks for one probe after it settles, its marks hollow until then; every paint of a new structure before its levels arrive is unlit, with hollow marks; sounds opened right after arriving in PATCH, with the probe as slow as a CI runner's render, are not announced as having kept you waiting; a PERFORM measurement nobody is waiting on (`bg`), sent just before a knob turn's probe, does not hold the probe or the cables' light back, and still finishes | | `patch_from_nothing.spec.js` | NEW PATCH leaves one empty socket and the amp, named *New patch* and counted in its caption; modules added from the rail; a processor deleted and put back with the toast's undo; a source deleted leaves its socket empty; CLEAR and its undo; BACK TO ‹name› reopens the sound, and NEW PATCH brings the new patch back; ⌘Z past its start ends it; Esc on a plate button (CAPTURE's RECORD) backs out to its plate and keeps the new patch | | `patch_sheet.spec.js` | On a coarse pointer, a tapped module opens a sheet with a row for every knob the engine describes (a slider with − and + of at least 44 px, or the setting's choices); + edits the knob through the lane, a choice sets a named setting and the arrows move between choices, focus goes into the sheet and leaves it with ×, a tap on another module opens that one; an AUDIO IN in the patch is drawn with its three settings, and its sheet has them; a tap on a plate button (CAPTURE's RECORD) presses it and opens no sheet | -| `space_after_a_click.spec.js` | A click leaves no focus on the wave or filter-mode chip, and Space then plays and leaves the chip alone (the second Space stops it); a chip reached with the keyboard cycles on Space and Enter and back with Shift, its name carrying its value and each cycle read out; in PERFORM, Space plays after a drag on a control, a click on the XY pad, and a click on a pad; the ⋯ menu's file items open their dialog on Enter and Space | +| `space_after_a_click.spec.js` | A click leaves no focus on the wave or filter-mode chip, and Space then plays and leaves the chip alone (the second Space stops it); a chip reached with the keyboard cycles on Space and Enter and back with Shift, its name carrying its value and each cycle read out; in PERFORM, Space plays after a drag on a control, a click on the XY pad, and a tap on Wander (which stays frozen); the ⋯ menu's file items open their dialog on Enter and Space | | `midi_announced.spec.js` | A MIDI knob that claims or learns a control is announced in a sentence (*CC 74 now moves Bright, the first free control.*), the later replacing the earlier | | `keys_for_the_platform.spec.js` | The ? card, the booth menu and the minimap's tooltip print ⌘ and ⇧ on an Apple platform and Ctrl and Shift elsewhere | | `responsive.spec.js` | The player is answered first while PERFORM measures; warm-start ▶; Take keeps its controls | diff --git a/docs/architecture/web-runtime.md b/docs/architecture/web-runtime.md index e21a5180..bc0b6340 100644 --- a/docs/architecture/web-runtime.md +++ b/docs/architecture/web-runtime.md @@ -1,7 +1,7 @@ --- title: "The web runtime: threads, lanes and the bench" -last_updated: 2026-10-02 -related_adrs: [1, 2, 7, 12, 15, 17] +last_updated: 2026-10-03 +related_adrs: [1, 2, 7, 12, 15, 17, 18] --- # The web runtime: threads, lanes and the bench @@ -76,7 +76,7 @@ unit-tested (`tests/levels.test.mjs`): A focused stop walks the rail with the plain arrows and Home/End, taken on the rail so EVOLVE's ←/→ never hear them. - **A move is instant.** The morph that carries the held sound's face from one - level to the next is PR C, and so is each level's `anchor()`. + level to the next is PR C3, and so is each level's `anchor()`. ### The model view @@ -452,6 +452,15 @@ bands × 12 slices (`auracle_features::face`), drawn against the bank. URL); after the bank changes, the bank's rows in view are redrawn the next frame and the rest when the page is idle. A slot is fixed-size and present whether or not its face has arrived, so no name moves for it. +- **Large faces** (Plan-008 C1): PERFORM's sound in hand and B, and each + EVOLVE card, are slots too (`FACE_SIZE` `well`, `wellb`, `evolve`), drawn + once per bank at a fixed size with the glow and the floor's reflection + (`FACE_OPTS`, through `drawVessel`) and scaled by the well that holds them + (`FACE_FLUID`: no fixed size on the slot, the image contained in it), at + 2× at most and in a cache of their own (`faceWellCache`, 24), apart from the + bank's 400 thumbnails. B's + is drawn in the model's amber, smaller, on the same floor line as the held + face's, so the two stand side by side in the well. - **Stage mode** (`stageDraw` in `perform.js`) draws the sound in hand's face (`host.faceOf(tree)`: the face and the bank) with `drawVessel` at full height, with its glow and reflection, on a still layer drawn again only @@ -759,6 +768,26 @@ While the warm start is open, `main.js` pre-warms its nine cards (`perform.prewarm(tree, {fresh: true})`, trees from the file, no pool inserts), one at a time in `later`, once the pool is full. +**The layout** (Plan-008 C1) is the specimen's well and panel: `.pf-left` +(the head, and the well with the held face, B, Blend, XY and How it works) +and `.pf-right` (CONTROLS, the deck, the hood, the pad row). The well has one +mode at a time (`setWellMode`: face, xy, how). Blend and Wander keep their +slots in `knobs` after the panel's controls (MIDI's and the keys' +`setControl`), wherever they are drawn: Blend a native range input under the +faces (`makeBlend`, shown only while B holds an offer), Wander the first cell +of the pad row; Freeze is a tap or Enter on Wander (`toggleHold`). PASS is +`passOffer`, the pass Next makes before it grows another. The moved bar shows +exactly when `movedFromHome()` (a `movedOn` reason for any knob, or a +structure other than home's), checked where `renderHood` already runs each +frame a knob moves, in a slot the head keeps, so nothing moves when it does. +Touch (what velocity plays) is a row of the Arrange panel. The pad keys +(ADR-018) are a `keydown` listener in `perform.js`: only while PERFORM shows, +never while typing, under a modal dialog (`host.blocked`, main's `modalUp`, +the same rule as the level keys) or with ⌘, Ctrl or ⌥; ↵ only with nothing +focused (a focused named control takes its own Enter and stops it). The first +steps are added to the guide pill (`guide.js`, `host.guide`), which keeps what +was done as `auracle-guide` and migrates `auracle-perform-steps` into it. + A continuous knob turned in PATCH reaches the voices as a parameter, never as a new tree, so PERFORM hears it separately: `sendEdit` calls `perform.knobSet` (PERFORM's base and home take the value at once), and the diff --git a/docs/decisions/018-the-pad-keys.md b/docs/decisions/018-the-pad-keys.md new file mode 100644 index 00000000..b331e242 --- /dev/null +++ b/docs/decisions/018-the-pad-keys.md @@ -0,0 +1,92 @@ +--- +title: "The pad keys: N, hold B, ⇧↵, ↵ and ⇧⌫" +number: 18 +status: accepted +author: Claude Code +created: 2026-10-03 +originating_proposal: 6 +superseded_by: null +--- + +# ADR-018: The pad keys + +## Status + +Accepted by the maintainer (Plan-008, maintainer decisions round 2: "Pad keys +(ADR-018)"). It supersedes ADR-009's pad row (RFC-003's Space, Enter, B, +⇧Enter and ⇧Space for PERFORM's pads), and settles what +[ADR-016](016-space-plays-everywhere.md) left open: Peek's key, and the rest +"decided when built". ADR-009's "N means next" stands, and so does ADR-016 +(Space plays the sound you're playing, in every view). ADR-017's level keys +are unchanged. + +## Context + +PERFORM had no pad keys: RFC-003's were never built, and ADR-016 took Space +from Peek. Plan-008 PR C1 lays PERFORM out to the approved prototype, whose +pads print N (Offer), B (Peek) and ⇧↵ (Take), with a fourth pad, PASS, and no +KEEP, BACK or FREEZE. The maintainer kept Keep, Back and Freeze as functions: +Keep and Back in a "moved · KEEP · BACK" bar shown when the sound has left +home, Freeze as a tap on Wander. The note keys (A W S E D F T G Y H U J K O L +P ; '), Z and X (octave), M (save), 1–5 (rate, and in EVOLVE 1 and 2 play its +pair), [ and ] (step the bank), / (PATCH's index), ? (the ? card, explain over +a control), ⇧F (stage mode in PERFORM), ⌥ with an arrow or a digit (the +levels), ← → in EVOLVE and R in LEARNING were taken; N and B were free +everywhere, Backspace in PERFORM (PATCH's rack deletes a module with it), and +Enter and ⇧Enter belong to whatever control has focus. + +## Decision + +In PERFORM, while it shows: + +- **N: Offer, or Next** while B holds an offer (a pass and another offer). + N is no note's key. In EVOLVE N is ANOTHER PAIR: N means next. +- **B held: Peek.** All of B while the key is down, back to Blend's mix when + it comes up, or when the window loses focus. +- **⇧↵: Take** the offer in B. Not from a focused button, link, drop-down or + text field, where Enter is theirs; a focused dial does not use ⇧↵, so it + takes from there too. +- **↵: Keep**, only when no control has focus (a focused button presses, a + focused control plays its sweep), and only when the sound has moved; + otherwise it says there is nothing to keep. A turn of a dial or Blend with + the mouse or a finger leaves no focus behind, as a click leaves none on a + button, so ↵ keeps straight after it; a control reached with Tab keeps its + focus and its own Enter. +- **⇧⌫: Back** to the last sound kept, when the sound has moved; otherwise it + says there is nothing to go back to. +- **Freeze has no key.** A tap on Wander freezes it, and Enter on a focused + Wander does the same. +- **PASS has no key.** It is a pass without growing another, and N is a pass + with one. + +None of them acts while typing in a text field, under a modal dialog (the +warm start, the commit pair, the ? card, stage mode, a lesson), or with ⌘, +Ctrl or ⌥ held. They are matched by the key's character, as the note keys +are. Each pad prints its key, and says it to assistive tech +(`aria-keyshortcuts`). + +## Options Considered + +### Option 1: RFC-003's keys + +Enter Take, ⇧Enter Keep, B Back, ⇧Space Freeze, with Peek moved to V. The +prototype's printed keys would change, and ⇧Space would take a modifier of +Space, which ADR-016 gives to play in every view. Rejected. + +### Option 2: The prototype's three, with Keep and Back only in the bar + +N, B and ⇧↵, and no keys for Keep and Back. Rejected: the bar is for the +pointer, and a keyboard player keeps and goes back as often as they take. + +### Option 3: The prototype's keys, ↵ Keep and ⇧⌫ Back (chosen) + +The three printed keys, Keep on the plainest key when nothing else wants it, +and Back on Shift with Backspace, which is "undo where I went" in many +instruments and is free in PERFORM. + +## Consequences + +- `apps/web/perform.js` takes the keys (the pad keys, after the stage keys); + `apps/web/main.js` takes N in EVOLVE. +- The keymap in `www/docs/src/keyboard.md` and the ? card say them. +- `apps/web/AGENTS.md`'s keymap line points here as well. diff --git a/docs/plans/008-the-shell.md b/docs/plans/008-the-shell.md index db9a1fdd..30fd9ab6 100644 --- a/docs/plans/008-the-shell.md +++ b/docs/plans/008-the-shell.md @@ -27,8 +27,10 @@ you move between them. It does not rebuild their internals. | PR | Status | | --- | --- | | A: the frame, the header, the level rail, the keys bar | merged (#112) | -| B: the bank and the model view | in review | -| C: the levels laid out, and the zoom | not started | +| B: the bank and the model view | merged (#115) | +| C1: PERFORM, EVOLVE, stage mode and the guide pill (levels still switch instantly) | in review | +| C2: PATCH rebuilt as the mock's canvas | not started | +| C3: the zoom (`anchor()`, the morph, the puck, pinch, ⌥-scroll, `takeUp`, `d-zoom`) | not started | | D: ⌘K, and the guide for the levels | not started | The maintainer accepted the plan with the decisions at its end, which override @@ -106,6 +108,87 @@ not framed). ADR-017 records §6 Q1. `capture-screens.mjs` run, which PR D does; `encode-screens.sh` has the new rectangles. +**PR C1, as built, where it differs from §1–§3** (PR C is split into C1, C2 +and C3; round 2's decisions govern PERFORM): +- **PERFORM's well and panel.** The left column is the head (the family · + in hand cap, the name at the display size with share, the blurb) and the + well; the right is CONTROLS (Arrange, How it works), the status line, the + knob grid (three to a row; four for a panel of seven or eight), the hood + strip and the pad row WANDER · OFFER · PEEK · TAKE · PASS. Every `pf-*` + class specs read stays on its node (`.pf-name`, `.pf-status`, `.pf-knob`, + `.pf-pad`, `.pf-offer`, `.pf-face`, `.pf-xy-*`, `.pf-why-*`). The live + scope survives as a small trace in the well's top left, clear of B and + Blend, so it stays while you compare; it gives way to XY and How it works. +- **The faces in the well are face slots** (`FACE_SIZE` `well` and `wellb`, + drawn with `FACE_OPTS`' glow and reflection and scaled by the well), not a + canvas drawn per frame, so a face redraws only when its render or the bank + changes (ADR-012), and B's is amber, smaller, on the held face's floor + line. Stage mode keeps its own canvas. +- **A click on the held face plays the phrase** (Space's ▶, as the mock's + well does, and a tap on a touch screen); a click on B does not, since B is + heard with PEEK or B held. +- **The *vel* tick** sits in its control's top-left corner rather than under + the name, so no word in the cell moves for it (a mark never moves a label). +- **Share** opens the existing picture panel on *the sound's card*; the + mock's share dialog (Copy image, Copy name) is not built. +- **Freeze has no `aria-pressed`.** Wander is a slider, and ARIA does not + allow `aria-pressed` on `role=slider`; frozen is its `aria-valuetext` + ("frozen"), its `.held` lit ring and `data-frozen`. Enter on a focused + Wander toggles it. +- **PASS reuses `passOffer`**, the pass Next already made before growing + another (heard: recorded as `perform_record` with `took: false` after the + pass window, with UNDO; unheard: *Skipped B* with UNDO). `clearOffer` + stays the silent clear on a patch change. +- **Blend** is a native range input in the well (`makeBlend`), shown only + while B holds an offer, kept in the page so a MIDI pot still lands; it and + Wander keep their `knobs` slots after the panel's controls. +- **The moved bar** is shown and hidden in a slot the cap row keeps; its + KEEP and BACK leave the tab order while the sound is home. Its printed keys + hide under 1180 px. +- **The pad row** is one row from 1361 px; narrower, WANDER takes a line of + its own above the four pads. At 1000 × 760 the whole right column fits + above the keybed (`perform_layout.spec.js`). +- **How it works** is the well's third mode (the mock opens an explain + figure from it instead; the control's figure stays on its `?`). +- **The guide pill** (`guide.js`) shows on PERFORM only, as its three steps + are PERFORM's; C3 adds the zoom and model-view steps and decides where it + shows then. PERFORM leaves it a row under the well. The bench, bank and + node-bank tours are untouched (⌘K in D). Booth attract's band moved from + the marquee row to the top of the well. +- **EVOLVE POOL dashed** means "before the first fit" (`views.styles`): a + generation then breeds from the grammar alone, a guess (ADR-012). It is + never disabled for it; the mock's *needs 6 picks* is not said, since the + engine breeds with none. +- **EVOLVE's scope** keeps its canvas over the well for the *rendering…* + sweep and *no audio for this one*, and draws no waveform; the card's style + badge and the model view's guess sit over the well's top. +- **The toast lane** steps over PERFORM's pad row (`LANE_STRIPS`). +- **The film selectors C1 leaves stale**, owed with the Wave 3 re-records + as A's and B's are: the KEEP, BACK and FREEZE pads, `.pf-xy*` at rest, + `.pf-step`, `#pf-touch-*` in the touch row, `#scope-a/b`, `#promote-a/b`, + `.lineage-strip` and `.pf-head` in `view-perform` (24 lines in + `shots.json` and `gen_shots.py`, 2 in its storyboard), `playing` (5, and + 5 in its storyboard), `perform` (2), `view-evolve` (3, and 1), + `sounddesign` (2) and `view-patch` (2); `capture-screens.mjs`'s PERFORM + and EVOLVE shots (3) go with D's full re-capture. +- **Not built here:** `anchor()`, the morph and the puck (C3); PATCH's + canvas (C2); the landing's and guide's full re-capture (D: only + `perform.webp` and `evolve.webp` were re-captured, by hand, as PR B did + its bank figures, from a session taught by the warm start alone: 18 + picks, no generations, Ceiling with an offer in B and no chord latched, + not SCREENSHOTS.md's taught session; `encode-screens.sh`'s `teach-meter` + rectangle is the old layout's). +- **The large faces have their own cache.** The well's and EVOLVE's faces + are drawn at 2× at most and kept in `faceWellCache`, an LRU of 24, apart + from the bank's 400 thumbnails, so their memory is bounded. +- **A pointer's turn leaves no focus.** A dial or Blend turned with the + mouse or a finger is blurred when the turn ends (as main.js leaves no + focus on a clicked button), so ↵ is KEEP straight after; a control reached + with Tab keeps its focus and its Enter. The pad keys treat Blend's range + input as a control, not a text field. +- **Touch sizes.** On a coarse pointer KEEP and BACK are 44 px tall and the + pill's × 40 px. + ## 1. Delta inventory (mock vs app) ### Header (mock `core.js` 398–423, `style.css` 81–135; app `index.html` 74–256, `style.css` 420–630) diff --git a/tests/web/booth.spec.js b/tests/web/booth.spec.js index 16d098af..e57bae96 100644 --- a/tests/web/booth.spec.js +++ b/tests/web/booth.spec.js @@ -28,9 +28,9 @@ test("attract plays in PERFORM, hands over on a key, and teaches nothing", async await page.keyboard.press("h"); await expect(page.locator("#booth-attract")).toHaveClass(/hidden/, { timeout: 5000 }); await page.waitForTimeout(1000); - for (const i of [6, 7]) { - await expect(page.locator(`.pf-knob[data-i="${i}"]`)).toHaveAttribute("aria-valuenow", "0.00"); - } + // Blend (the slider in the well) and Wander (at the start of the pads) home. + await expect(page.locator('.pf-blend[data-i="6"] input')).toHaveAttribute("aria-valuenow", "0.00"); + await expect(page.locator('.pf-knob[data-i="7"]')).toHaveAttribute("aria-valuenow", "0.00"); expect(await page.locator("#duel-count").textContent()).toBe(picks); expect(errs).toEqual([]); }); diff --git a/tests/web/evolve_cards.spec.js b/tests/web/evolve_cards.spec.js new file mode 100644 index 00000000..b1b024fc --- /dev/null +++ b/tests/web/evolve_cards.spec.js @@ -0,0 +1,97 @@ +// EVOLVE's cards to the specimen (Plan-008 PR C1, `z-desk-evolve.png`). +// +// - Each card's well holds its sound's face, large (the scope's waveform it +// replaces), with ⇄ circuit and ↓ patch in its corner: the circuit swaps +// the face for the patch, and ↓ patch opens the sound in PATCH. +// - The buttons are relabelled (PLAY · 1, PICK A · ←, ANOTHER PAIR · N) and +// keep their ids; the head asks the question, with a small TASTE map that +// goes to TASTE. +// - What each generation did is a disclosure under EVOLVE POOL, opened over +// the foot of the cards, and Esc folds it. +// - EVOLVE POOL is dashed until the model has learned from your picks. +const { test, expect } = require("@playwright/test"); +const { goLevel } = require("./shell"); + +async function boot(page) { + const errs = []; + page.on("pageerror", (e) => errs.push(e.message)); + await page.goto("/"); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 120_000 }); + await page.locator("#warm-skip").click(); + await goLevel(page, "evolve"); + await expect(page.locator("#name-a")).not.toHaveText("sound a", { timeout: 90_000 }); + return errs; +} + +test("each card's well holds its sound's face, with ⇄ circuit and ↓ patch in its corner", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + const face = page.locator("#face-a.face-slot.face-evolve img.face"); + await expect(face).toHaveCount(1, { timeout: 60_000 }); + await expect(page.locator("#face-b.face-slot.face-evolve img.face")).toHaveCount(1, { timeout: 60_000 }); + // Large: it fills the card's well, as the waveform did. + const well = await page.locator("#duel-a .duel-well").boundingBox(); + const fb = await face.boundingBox(); + expect(fb.height).toBeGreaterThan(well.height * 0.6); + // Its corner: the circuit and open in PATCH. + const corner = page.locator("#duel-a .duel-corner"); + await expect(corner.locator("#flip-a")).toHaveText("⇄ circuit"); + await expect(corner.locator("#promote-a")).toHaveText("↓ patch"); + // ⇄ circuit swaps the face for the patch, and back. + await page.locator("#flip-a").click(); + await expect(page.locator("#mini-a")).toBeVisible(); + await expect(page.locator("#face-a")).toBeHidden(); + await expect(page.locator("#flip-a")).toHaveText("⇄ face"); + await page.locator("#flip-a").click(); + await expect(page.locator("#face-a")).toBeVisible(); + await expect(page.locator("#mini-a")).toBeHidden(); + // ↓ patch opens the sound in PATCH. + const name = (await page.locator("#name-a").evaluate((e) => e.firstChild.textContent)).trim(); + await page.locator("#promote-a").click(); + await expect(page.locator('.rail-stop[data-level="patch"]')).toHaveAttribute("aria-current", "location"); + await expect(page.locator("#rack-subject")).toContainText(name, { timeout: 60_000 }); + expect(errs).toEqual([]); +}); + +test("the buttons keep their ids under the specimen's words, and the small map goes to TASTE", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await expect(page.locator("#view-evolve .ev-title")).toHaveText("Pick the one you’d reach for."); + await expect(page.locator("#play-a")).toHaveText(/^▶ play\s*1$/); + await expect(page.locator("#play-b")).toHaveText(/^▶ play\s*2$/); + await expect(page.locator("#choose-a")).toHaveText(/^pick a\s*←$/); + await expect(page.locator("#choose-b")).toHaveText(/^pick b\s*→$/); + await expect(page.locator("#skip-duel")).toHaveText(/^another pair\s*N$/); + await expect(page.locator("#evolve-btn")).toHaveText("evolve pool"); + await expect(page.locator("#teach-pips i")).toHaveCount(6); + // No picks have taught it yet: EVOLVE POOL is dashed, and still pressable. + await expect(page.locator("#evolve-btn")).toHaveClass(/\buntaught\b/); + await expect(page.locator("#evolve-btn")).toBeEnabled(); + // The small map is TASTE's, and goes there. + await page.locator("#ev-map").click(); + await expect(page.locator('.rail-stop[data-level="taste"]')).toHaveAttribute("aria-current", "location"); + expect(errs).toEqual([]); +}); + +test("what each generation did opens over the foot of the cards, and Esc folds it", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + const btn = page.locator("#lineage-btn"); + const pop = page.locator("#lineage-pop"); + await expect(pop).toBeHidden(); + await expect(btn).toHaveAttribute("aria-expanded", "false"); + await btn.click(); + await expect(pop).toBeVisible(); + await expect(btn).toHaveAttribute("aria-expanded", "true"); + await expect(pop.locator("#lineage-log")).toContainText("No generations yet"); + // Over the foot of the cards: it overlaps the cards' bottom edge. + const card = await page.locator("#duel-a").boundingBox(); + const p = await pop.boundingBox(); + expect(p.y).toBeLessThan(card.y + card.height); + expect(p.y + p.height).toBeGreaterThan(card.y + card.height * 0.5); + await page.keyboard.press("Escape"); + await expect(pop).toBeHidden(); + await expect(btn).toHaveAttribute("aria-expanded", "false"); + expect(await page.evaluate(() => document.activeElement?.id)).toBe("lineage-btn"); + expect(errs).toEqual([]); +}); diff --git a/tests/web/faces.spec.js b/tests/web/faces.spec.js index 63002f9d..b1f3ee3e 100644 --- a/tests/web/faces.spec.js +++ b/tests/web/faces.spec.js @@ -125,7 +125,7 @@ test("a face appears on every row, card and chip once its render lands", async ( await expect(page.locator("#pd-b .face-slot img.face")).toHaveCount(1); // PERFORM: the sound in hand, and B once an offer has grown. await goLevel(page, "perform"); - await expect(page.locator(".pf-head .pf-face img.face")).toHaveCount(1, { timeout: 60_000 }); + await expect(page.locator(".pf-faces > .pf-face img.face")).toHaveCount(1, { timeout: 60_000 }); await expect(page.locator(".pf-offer .pf-face img.face")).toHaveCount(0); await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); await page.evaluate(() => { @@ -137,7 +137,7 @@ test("a face appears on every row, card and chip once its render lands", async ( await expect(page.locator(".pf-offer .pf-face img.face")).toHaveCount(1, { timeout: 30_000 }); // The offer's face is its own render's, not the sound in hand's. const [held, offer] = await page.evaluate(async () => [ - await window.__pwOutline(document.querySelector(".pf-head .pf-face img.face")), + await window.__pwOutline(document.querySelector(".pf-faces > .pf-face img.face")), await window.__pwOutline(document.querySelector(".pf-offer .pf-face img.face")), ]); expect(held).not.toBe(""); diff --git a/tests/web/guide_pill.spec.js b/tests/web/guide_pill.spec.js new file mode 100644 index 00000000..408c562a --- /dev/null +++ b/tests/web/guide_pill.spec.js @@ -0,0 +1,103 @@ +// The guide pill (Plan-008 PR C1, ADR-009's one onboarding surface): the +// first-visit steps, one at a time, bottom left of the stage, with pips and +// ×. PERFORM's three first steps are its steps. +// +// - It shows the step that is next, ticks it off when it happens (a note +// played, a control turned, an offer asked for), and after the last says +// what the loop was, then goes. +// - It sits bottom left of the stage, under PERFORM's well, and shows on +// PERFORM only (its steps are PERFORM's). +// - × stops it, and a reload keeps it stopped (`auracle-guide`). +// - The first steps' ticks kept before the pill (`auracle-perform-steps`) +// carry over: the pill opens at the next step, and the old key is gone. +const { test, expect } = require("@playwright/test"); +const { goLevel } = require("./shell"); + +async function boot(page, { seed = null } = {}) { + const errs = []; + page.on("pageerror", (e) => errs.push(e.message)); + if (seed) { + await page.addInitScript((s) => { + try { + if (!sessionStorage.getItem("seeded")) { + for (const [k, v] of Object.entries(s)) localStorage.setItem(k, v); + sessionStorage.setItem("seeded", "1"); + } + } catch (_) {} + }, seed); + } + await page.goto("/"); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 120_000 }); + await page.locator("#warm-skip").click(); + await goLevel(page, "perform"); + return errs; +} + +const pill = (page) => page.locator("#guide"); +const pips = (page) => page.locator("#guide .pips i"); + +test("the pill shows one step at a time and ticks each off as it happens", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); + await expect(pill(page)).toBeVisible(); + await expect(page.locator("#guide .pf-step")).toHaveCount(1); + await expect(page.locator("#guide .pf-step.now")).toContainText("Play a key"); + await expect(pips(page)).toHaveCount(3); + await expect(page.locator("#guide .pips i.done")).toHaveCount(0); + // Bottom left of the stage, under the well. + const g = await pill(page).boundingBox(); + const well = await page.locator(".pf-well").boundingBox(); + const stage = await page.locator(".stage").boundingBox(); + expect(g.x).toBeLessThan(well.x + well.width); + expect(g.y).toBeGreaterThanOrEqual(well.y + well.height - 1); + expect(g.y + g.height).toBeLessThanOrEqual(stage.y + stage.height); + // A note: step 2, which names a control that turns here. + await page.keyboard.press("a"); + await expect(page.locator("#guide .pf-step.now")).toContainText(/^Turn [A-Z]+: drag/); + await expect(page.locator("#guide .pips i.done")).toHaveCount(1); + // A turn: step 3. + const k = page.locator(".pf-deck .pf-knob:not(.search):not(.pending)").first(); + await k.focus(); + for (let i = 0; i < 6; i++) await page.keyboard.press(/half-hi/.test(await k.getAttribute("class")) ? "ArrowDown" : "ArrowUp"); + await expect(page.locator("#guide .pf-step.now")).toContainText("Press OFFER"); + // Off PERFORM it does not show; back, it does. + await goLevel(page, "patch"); + await expect(pill(page)).toBeHidden(); + await goLevel(page, "perform"); + await expect(pill(page)).toBeVisible(); + // An offer asked for: the last step, said once, then the pill goes. + await page.locator(".pf-pad", { hasText: /^Offer$/ }).click(); + await expect(page.locator("#guide .pf-step.all")).toContainText("That is the loop"); + await expect(pill(page)).toBeHidden({ timeout: 15_000 }); + const kept = await page.evaluate(() => JSON.parse(localStorage.getItem("auracle-guide"))); + expect(kept.done.sort()).toEqual(["offer", "play", "turn"]); + expect(errs).toEqual([]); +}); + +test("× stops the pill, and a reload keeps it stopped", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await expect(pill(page)).toBeVisible(); + await page.locator("#guide .x").click(); + await expect(pill(page)).toBeHidden(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem("auracle-guide")).closed)).toBe(true); + await page.reload(); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 120_000 }); + await goLevel(page, "perform"); + await expect(page.locator(".pf-name")).not.toHaveText("·", { timeout: 60_000 }); + await expect(pill(page)).toBeHidden(); + expect(errs).toEqual([]); +}); + +test("the first steps' old ticks carry over into the pill, and the old key goes", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page, { seed: { "auracle-perform-steps": JSON.stringify(["play"]) } }); + await expect(pill(page)).toBeVisible(); + await expect(page.locator("#guide .pf-step.now")).toContainText(/^Turn /); + await expect(page.locator("#guide .pips i.done")).toHaveCount(1); + const store = await page.evaluate(() => ({ old: localStorage.getItem("auracle-perform-steps"), now: JSON.parse(localStorage.getItem("auracle-guide")) })); + expect(store.old).toBeNull(); + expect(store.now).toEqual({ done: ["play"], closed: false }); + expect(errs).toEqual([]); +}); diff --git a/tests/web/pad_keys.spec.js b/tests/web/pad_keys.spec.js new file mode 100644 index 00000000..312ce425 --- /dev/null +++ b/tests/web/pad_keys.spec.js @@ -0,0 +1,188 @@ +// PERFORM's pad keys (ADR-018): N offers (Next while B holds one), B held +// peeks, ⇧↵ takes, ↵ keeps with no control focused, ⇧⌫ goes back. N is +// ANOTHER PAIR in EVOLVE. +// +// - N asks for an offer, and with one in B it passes on it and asks again. +// B held is PEEK held (the pad pressed while the key is down). ⇧↵ takes B. +// - ↵ keeps only when no control has focus: with a dial focused it is the +// dial's own (its long-press sweep), and nothing is kept. ⇧⌫ goes back. With +// the sound at home, each says there is nothing to do. +// - The keys yield to a text field and to a modal dialog, and the note keys +// still play in PERFORM. +const { test, expect } = require("@playwright/test"); +const { goLevel, bankTab } = require("./shell"); +const budget = require("./perform_budget.js"); + +const INIT = `(() => { + const offers = (window.__offers = []); + const Orig = window.Worker; + function Wrapped(url, o) { + const w = new Orig(url, o); + if (/worker\\.js/.test(String(url))) { + const post = w.postMessage.bind(w); + w.postMessage = (m, t) => { + if (m && m.type === "perform_offer" && !m.bg) offers.push(performance.now()); + return post(m, t); + }; + } + return w; + } + Wrapped.prototype = Orig.prototype; + window.Worker = Wrapped; +})();`; + +async function boot(page) { + const errs = []; + page.on("pageerror", (e) => errs.push(e.message)); + await budget.watch(page); + await page.addInitScript(INIT); + await page.goto("/"); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 120_000 }); + await page.locator("#warm-skip").click(); + return errs; +} + +async function openOnPerform(page, name) { + await bankTab(page, "presets"); + await page.locator(".bank-item", { hasText: name }).first().click(); + await page.waitForFunction((n) => (document.getElementById("rack-subject")?.textContent || "").includes(n), name, { timeout: 90_000 }); + await goLevel(page, "perform"); + await expect(page.locator(".pf-name")).toHaveText(name, { timeout: 30_000 }); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); +} + +const blur = (page) => page.evaluate(() => document.activeElement?.blur()); +const offersAsked = (page) => page.evaluate(() => window.__offers.length); + +/** Drag a dial vertically by `dy` px (negative is up) with the mouse. */ +async function drag(page, loc, dy) { + const b = await loc.boundingBox(); + const x = b.x + b.width / 2; + const y = b.y + b.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + dy, { steps: 10 }); + await page.mouse.up(); +} + +/** Slide Blend, under the faces, most of the way to B with the mouse. */ +async function slideBlend(page) { + const b = await page.locator(".pf-blend input").boundingBox(); + await page.mouse.move(b.x + 2, b.y + b.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + b.width * 0.8, b.y + b.height / 2, { steps: 8 }); + await page.mouse.up(); +} + +test("N offers and, with B full, passes and offers again; B held peeks; ⇧↵ takes, after Blend too", { tag: "@slow" }, async ({ page }) => { + test.setTimeout(420_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const OFFER_MS = await budget.offerBudget(page, { waits: 2 }); + const n0 = await offersAsked(page); + await page.keyboard.press("n"); + await expect.poll(() => offersAsked(page)).toBe(n0 + 1); + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: OFFER_MS }); + await expect(page.locator(".pf-pad.primary")).toHaveText("Next"); + // Blend slid with the mouse, then B held is PEEK held; let go, it is up. + await slideBlend(page); + expect(Number(await page.locator(".pf-blend input").getAttribute("aria-valuenow"))).toBeGreaterThan(0.3); + const peekPad = page.locator(".pf-pad", { hasText: "Peek" }); + await page.keyboard.down("b"); + await expect(peekPad).toHaveClass(/\bdown\b/); + await page.keyboard.up("b"); + await expect(peekPad).not.toHaveClass(/\bdown\b/); + // With Blend focused from the keyboard (a range is a control, not a text + // field), N is still Next: a pass on B (unheard, so not counted) and + // another offer. + await page.locator(".pf-blend input").focus(); + await page.keyboard.press("n"); + await expect(page.locator("#toasts")).toContainText("Skipped B."); + await expect.poll(() => offersAsked(page)).toBe(n0 + 2); + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: OFFER_MS }); + // Blend slid again with the mouse (which leaves no focus), and focused from + // the keyboard too: ⇧↵ takes either way, and the sound in hand is the offer. + await slideBlend(page); + expect(await page.evaluate(() => document.activeElement === document.body), "a slide leaves no focus").toBe(true); + await page.locator(".pf-blend input").focus(); + await page.keyboard.press("Shift+Enter"); + await expect(page.locator(".pf-name")).toHaveText("Glass Pad (taken offer)", { timeout: 60_000 }); + await expect(page.locator(".pf-offer")).not.toHaveClass(/\bready\b/); + expect(errs).toEqual([]); +}); + +test("↵ keeps after a mouse turn, a dial reached with the keyboard keeps its own Enter, ⇧⌫ goes back, and at home each says there is nothing to do", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const bar = page.locator(".pf-moved"); + // At home: nothing to keep, nothing to go back to. (The level's stop the + // spec clicked to get here is a button, whose Enter is its own.) + await blur(page); + await page.keyboard.press("Enter"); + await expect(page.locator("#toasts")).toContainText("Nothing to keep: this sound is home."); + await page.keyboard.press("Shift+Backspace"); + await expect(page.locator("#toasts")).toContainText("Nothing to go back to: this sound is home."); + // A dial reached with the keyboard and turned there keeps its focus, and + // ↵ is its own (its sweep through both ends and back): nothing is kept. + // Waited out, so the sweep's return does not land after the Keep below. + const k = page.locator(".pf-deck .pf-knob:not(.search):not(.pending):not(.half-hi)").first(); + await k.focus(); + for (let i = 0; i < 8; i++) await page.keyboard.press("ArrowUp"); + await expect(bar).toHaveClass(/\bon\b/); + const at = await k.getAttribute("aria-valuenow"); + await page.keyboard.press("Enter"); + await expect(k).not.toHaveAttribute("aria-valuenow", at, { timeout: 5_000 }); + await expect(k).toHaveAttribute("aria-valuenow", at, { timeout: 10_000 }); + await expect(bar).toHaveClass(/\bon\b/); + await expect(page.locator("#toasts")).not.toContainText("Kept: this is home now."); + // A dial turned with the mouse leaves no focus: ↵ is KEEP. + await drag(page, k, -60); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true); + await page.keyboard.press("Enter"); + await expect(page.locator("#toasts")).toContainText("Kept: this is home now.", { timeout: 15_000 }); + await expect(bar).not.toHaveClass(/\bon\b/); + // Moved again with the mouse, ⇧⌫ glides back home. + await drag(page, k, -60); + await expect(bar).toHaveClass(/\bon\b/); + await page.keyboard.press("Shift+Backspace"); + await expect(bar).not.toHaveClass(/\bon\b/, { timeout: 10_000 }); + expect(errs).toEqual([]); +}); + +test("the pad keys yield to a text field and a modal, the note keys still play, and N in EVOLVE is another pair", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const n0 = await offersAsked(page); + // Typed into Find a sound: letters, not an offer or a peek. + const find = page.locator("#bank-find"); + await find.click(); + await page.keyboard.type("nb"); + await expect(find).toHaveValue("nb"); + await find.fill(""); + await expect(page.locator(".pf-pad", { hasText: "Peek" })).not.toHaveClass(/\bdown\b/); + // Under the ? card (a modal): nothing either. + await blur(page); + await page.keyboard.press("?"); + await expect(page.locator("#help")).toBeVisible(); + await page.keyboard.press("n"); + await page.keyboard.press("Escape"); + await expect(page.locator("#help")).toBeHidden(); + // An offer is asked for in the keydown itself, so none asked by now is none. + expect(await offersAsked(page), "no offer asked for from a text field or under a modal").toBe(n0); + // The note keys still play in PERFORM, beside the pad keys. + await page.keyboard.down("a"); + await expect(page.locator('.pkey[data-note="60"]')).toHaveClass(/\bdown\b/); + await page.keyboard.up("a"); + // In EVOLVE, N deals another pair. + await goLevel(page, "evolve"); + const name = page.locator("#name-a"); + await expect(name).not.toHaveText("sound a", { timeout: 60_000 }); + const before = await name.textContent(); + await expect(async () => { + await page.keyboard.press("n"); + await expect(name).not.toHaveText(before, { timeout: 5_000 }); + }).toPass({ timeout: 60_000 }); + expect(errs).toEqual([]); +}); diff --git a/tests/web/perform_controls.spec.js b/tests/web/perform_controls.spec.js index 138de8bb..fbc30b24 100644 --- a/tests/web/perform_controls.spec.js +++ b/tests/web/perform_controls.spec.js @@ -78,6 +78,8 @@ test("an XY axis the patch can't move strikes its end words through", async ({ p [0, 1, 2, 3, 4, 5].find((k) => document.querySelector(`.pf-knob[data-i="${k}"]`)?.classList.contains("search")), ); expect(i, "Glass Pad has a search control").not.toBeUndefined(); + // The XY pad is a mode of the well, opened from its corner. + await page.locator(".pf-xy-btn").click(); const sels = page.locator(".pf-xy select"); await expect(sels).toHaveCount(2); await sels.nth(1).selectOption(String(i)); diff --git a/tests/web/perform_layout.spec.js b/tests/web/perform_layout.spec.js new file mode 100644 index 00000000..12778070 --- /dev/null +++ b/tests/web/perform_layout.spec.js @@ -0,0 +1,240 @@ +// PERFORM is laid out as the specimen's well and panel (Plan-008 PR C1), and +// every function it had keeps a place in it. +// +// - At 1000, 1280 and 1440 px the sound is on the left (the cap, the name, +// the well with its face) and what you turn is on the right (CONTROLS, the +// knobs, the hood, the pad row WANDER · OFFER · PEEK · TAKE · PASS), with +// the pads above the keybed and no pad's words past its edge. +// - XY is a mode of the well: its button swaps the face (dimmed behind) for +// the field, and the button or Esc puts the face back. +// - Blend is a slider under the two faces, shown only while B holds an +// offer. +// - Freeze is a tap on Wander (or Enter on it), and says so on Wander. +// - The moved bar shows exactly when the sound has left home; BACK glides it +// home and KEEP makes it home, and either way the bar goes. +// - PASS passes on B without growing another: heard, it is recorded as +// Next's pass is (`perform_record`, took false) after its window, and its +// UNDO brings B back. +const { test, expect } = require("@playwright/test"); +const { goLevel, bankTab } = require("./shell"); +const budget = require("./perform_budget.js"); + +const INIT = `(() => { + const records = (window.__records = []); + const offers = (window.__offers = []); + const Orig = window.Worker; + function Wrapped(url, o) { + const w = new Orig(url, o); + if (/worker\\.js/.test(String(url))) { + const post = w.postMessage.bind(w); + w.postMessage = (m, t) => { + if (m && m.type === "perform_record") records.push({ took: !!m.took, at: performance.now() }); + if (m && m.type === "perform_offer" && !m.bg) offers.push(performance.now()); + return post(m, t); + }; + } + return w; + } + Wrapped.prototype = Orig.prototype; + window.Worker = Wrapped; +})();`; + +async function boot(page) { + const errs = []; + page.on("pageerror", (e) => errs.push(e.message)); + await budget.watch(page); + await page.addInitScript(INIT); + await page.goto("/"); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 120_000 }); + await page.locator("#warm-skip").click(); + return errs; +} + +async function openOnPerform(page, name) { + await bankTab(page, "presets"); + await page.locator(".bank-item", { hasText: name }).first().click(); + await page.waitForFunction((n) => (document.getElementById("rack-subject")?.textContent || "").includes(n), name, { timeout: 90_000 }); + await goLevel(page, "perform"); + await expect(page.locator(".pf-name")).toHaveText(name, { timeout: 30_000 }); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); +} + +/** A control that turns up on this patch, focused. */ +async function turnUp(page, presses = 8) { + const k = page.locator(".pf-deck .pf-knob:not(.search):not(.pending):not(.half-hi)").first(); + await k.focus(); + for (let i = 0; i < presses; i++) await page.keyboard.press("ArrowUp"); + return k; +} + +async function grow(page, ms) { + await page.locator(".pf-pad", { hasText: /^(Offer|Next)$/ }).click(); + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: ms }); +} + +async function peek(page, ms) { + const b = await page.locator(".pf-pad", { hasText: "Peek" }).boundingBox(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); + await page.mouse.down(); + await page.waitForTimeout(ms); + await page.mouse.up(); + await page.mouse.move(10, 10); +} + +for (const [width, height] of [[1000, 760], [1280, 800], [1440, 900]]) { + test.describe(`a ${width} px window`, () => { + test.use({ viewport: { width, height } }); + test(`PERFORM is a well and a panel at ${width} px, its pads above the keybed and whole`, async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + await expect(page.locator(".pf-faces > .pf-face img.face")).toHaveCount(1, { timeout: 60_000 }); + const box = await page.evaluate(() => { + const r = (sel) => { + const b = document.querySelector(sel).getBoundingClientRect(); + return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; + }; + const pads = [...document.querySelectorAll(".pf-pads > *")].map((e) => e.getAttribute("aria-label") || e.textContent.trim()); + const cut = [...document.querySelectorAll(".pf-pads .pf-pad, .pf-pads .pf-k-name, .pf-ctlhead button, .pf-cap")] + .filter((e) => e.scrollWidth > e.clientWidth + 1) + .map((e) => e.textContent.trim()); + return { left: r(".pf-left"), right: r(".pf-right"), well: r(".pf-well"), deck: r(".pf-deck"), pads: r(".pf-pads"), keybar: r(".keybar"), order: pads, cut }; + }); + // Two columns: the sound on the left, what you turn on the right. + expect(box.right.left, "the panel is right of the sound").toBeGreaterThanOrEqual(box.left.right - 1); + expect(box.well.width).toBeGreaterThan(200); + expect(box.well.height).toBeGreaterThan(200); + expect(box.deck.left).toBeGreaterThanOrEqual(box.right.left - 1); + // The pad row, in its order, above the keybed, and nothing in it cut. + expect(box.order).toEqual(["Wander", "Offer", "Peek", "Take", "Pass"]); + expect(box.pads.bottom, "the pads are above the keybed").toBeLessThanOrEqual(box.keybar.top + 1); + expect(box.cut, "words past their edge").toEqual([]); + expect(errs).toEqual([]); + }); + }); +} + +test("XY is a mode of the well: the button swaps the face for the field, and the button or Esc puts it back", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const well = page.locator(".pf-well"); + const btn = page.locator(".pf-xy-btn"); + await expect(page.locator(".pf-xy")).toBeHidden(); + await btn.click(); + await expect(btn).toHaveAttribute("aria-pressed", "true"); + await expect(well).toHaveAttribute("data-mode", "xy"); + await expect(page.locator(".pf-xy-field")).toBeVisible(); + await expect(page.locator(".pf-xy select")).toHaveCount(2); + // The face stays, dimmed behind the field. + expect(Number(await page.locator(".pf-faces").evaluate((e) => getComputedStyle(e).opacity))).toBeLessThan(0.5); + // The field moves the knobs it is on. + const f = await page.locator(".pf-xy-field").boundingBox(); + await page.mouse.click(f.x + f.width * 0.8, f.y + f.height * 0.2); + // Esc puts the face back, and focus on the button that opens it. + await page.locator(".pf-xy-field").focus(); + await page.keyboard.press("Escape"); + await expect(well).toHaveAttribute("data-mode", "face"); + await expect(btn).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator(".pf-xy")).toBeHidden(); + expect(await page.evaluate(() => document.activeElement?.classList.contains("pf-xy-btn"))).toBe(true); + // …and so does the button, pressed again. + await btn.click(); + await expect(well).toHaveAttribute("data-mode", "xy"); + await btn.click(); + await expect(well).toHaveAttribute("data-mode", "face"); + // How it works is the well's other mode, one at a time. + await page.locator(".pf-why-btn").click(); + await expect(well).toHaveAttribute("data-mode", "how"); + await expect(page.locator(".pf-why-body")).toBeVisible(); + await btn.click(); + await expect(well).toHaveAttribute("data-mode", "xy"); + await expect(page.locator(".pf-why-body")).toBeHidden(); + expect(errs).toEqual([]); +}); + +test("Freeze is a tap on Wander, or Enter on it, and Wander says so", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const wander = page.locator(".pf-pads .pf-wander"); + await expect(wander).toHaveAttribute("data-frozen", "false"); + await expect(page.locator(".pf-pad", { hasText: /^Freeze$/ })).toHaveCount(0); + await wander.click(); + await expect(wander).toHaveAttribute("data-frozen", "true"); + await expect(wander).toHaveClass(/\bheld\b/); + await expect(wander.locator(".pf-k-sub")).toHaveText("frozen"); + await expect(wander).toHaveAttribute("aria-valuetext", "frozen"); + // Enter on the focused dial: unfrozen, and the dial did not turn. + const v = await wander.getAttribute("aria-valuenow"); + await wander.focus(); + await page.keyboard.press("Enter"); + await expect(wander).toHaveAttribute("data-frozen", "false"); + await expect(wander).toHaveAttribute("aria-valuenow", v); + expect(errs).toEqual([]); +}); + +test("the moved bar shows only when the sound has left home, and KEEP and BACK settle it", async ({ page }) => { + test.setTimeout(240_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const bar = page.locator(".pf-moved"); + await expect(bar).not.toHaveClass(/\bon\b/); + await expect(bar).toBeHidden(); + // Its slot is kept: nothing in the head moves when it comes. + const nameAt = await page.locator(".pf-name").boundingBox(); + const k = await turnUp(page); + await expect(bar).toHaveClass(/\bon\b/); + await expect(bar).toBeVisible(); + await expect(bar).toContainText("moved"); + expect(await page.locator(".pf-name").boundingBox()).toEqual(nameAt); + // BACK glides home: the bar goes once the glide has landed. + await page.locator(".pf-moved .pf-back").click(); + await expect(bar).not.toHaveClass(/\bon\b/, { timeout: 10_000 }); + // Moved again, KEEP makes it home. + await turnUp(page); + await expect(bar).toHaveClass(/\bon\b/); + await page.locator(".pf-moved .pf-keep").click(); + await expect(page.locator("#toasts")).toContainText("Kept: this is home now.", { timeout: 15_000 }); + await expect(bar).not.toHaveClass(/\bon\b/); + await expect(k).toHaveAttribute("aria-valuenow", "0.00"); + expect(errs).toEqual([]); +}); + +test("Blend shows only while B holds an offer, and PASS passes on B without growing another", { tag: "@slow" }, async ({ page }) => { + test.setTimeout(420_000); + const errs = await boot(page); + await openOnPerform(page, "Glass Pad"); + const OFFER_MS = await budget.offerBudget(page, { waits: 2 }); + const slot = page.locator(".pf-blend-slot"); + const pass = page.locator(".pf-pad.pf-pass"); + await expect(slot).toBeHidden(); + await expect(pass).toBeDisabled(); + // An offer: Blend under the two faces, and PASS ready. + await grow(page, OFFER_MS); + await expect(slot).toBeVisible(); + await expect(page.locator(".pf-well")).toHaveClass(/\boffered\b/); + await expect(pass).toBeEnabled(); + // Heard (a note sounding, a second and a half of Peek), then passed. + await page.keyboard.down("a"); + await peek(page, 1600); + await page.keyboard.up("a"); + const asked = await page.evaluate(() => window.__offers.length); + await pass.click(); + await expect(page.locator(".pf-offer")).not.toHaveClass(/\bready\b/); + await expect(slot).toBeHidden(); + await expect(page.locator("#toasts")).toContainText("Passed on B. That counts as a pick for what you had."); + // No offer was asked for: a pass, not a Next (an offer is asked in the + // press itself, so none by now is none). + expect(await page.evaluate(() => window.__offers.length), "PASS grows nothing").toBe(asked); + await expect(page.locator(".pf-pad.primary")).toHaveText("Offer"); + // UNDO brings B back, heard as it was, and nothing is recorded. + await page.locator("#toasts .toast-undo", { hasText: "undo" }).last().click(); + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/); + await expect(slot).toBeVisible(); + // Passed again, and left: after its window it is recorded as Next's pass + // is, a pick for what you had. + await pass.click(); + await expect.poll(() => page.evaluate(() => window.__records.slice()), { timeout: 30_000 }).toEqual([expect.objectContaining({ took: false })]); + expect(errs).toEqual([]); +}); diff --git a/tests/web/perform_offer_latency.spec.js b/tests/web/perform_offer_latency.spec.js index 7c2e31d3..1bbb96be 100644 --- a/tests/web/perform_offer_latency.spec.js +++ b/tests/web/perform_offer_latency.spec.js @@ -193,7 +193,7 @@ test("a pick and a Keep are answered while a spare offer grows", { tag: "@slow" await drag(page, bright, -150); expect(Number(await bright.getAttribute("aria-valuenow"))).toBeGreaterThan(0.5); const keepAt = Date.now(); - await page.locator(".pf-pad", { hasText: "Keep" }).click(); + await page.locator(".pf-moved .pf-keep").click(); await expect(page.locator("#toasts")).toContainText("Kept: this is home now.", { timeout: KEEP_MS }); console.log(`Keep said so in ${Date.now() - keepAt} ms with the spare growing`); diff --git a/tests/web/perform_offer_moments.spec.js b/tests/web/perform_offer_moments.spec.js index 9a1eb7b2..a5fcae60 100644 --- a/tests/web/perform_offer_moments.spec.js +++ b/tests/web/perform_offer_moments.spec.js @@ -7,8 +7,9 @@ // place. // - Taken, B fills with green and goes into the face: the bench takes its // tree, and it is the sound you play. -// - Passed, B folds back into the face it grew from: it is dropped, and the -// sound you had is the sound you play. +// - Passed (NEXT, or PASS), B folds back into the face it grew from, where +// the face stands once the well has reflowed: it is dropped, and the sound +// you had is the sound you play. // - The heard rule, unchanged: an offer can be taken heard or not, and only a // heard one (a second of PEEK, or of BLEND past half, while a note sounds) // is recorded as a pick. @@ -108,7 +109,7 @@ function boxOf(transform, at) { return { left: at.left + dx, top: at.top + dy, width: at.width * sx, height: at.height * sy }; } const near = (a, b, px = 2) => ["left", "top", "width", "height"].every((k) => Math.abs(a[k] - b[k]) <= px); -const faceBox = (page) => page.locator(".pf-head .pf-face").evaluate((e) => { +const faceBox = (page) => page.locator(".pf-faces > .pf-face").evaluate((e) => { const r = e.getBoundingClientRect(); return { left: r.left, top: r.top, width: r.width, height: r.height }; }); @@ -117,13 +118,15 @@ const anims = (page) => page.evaluate(() => window.__anims.slice()); test("an offer grows from the sound in hand, fills when taken, and folds back when passed", { tag: "@slow" }, async ({ page }) => { test.setTimeout(420_000); const errs = await boot(page); - const OFFER_MS = await openOnPerform(page, "Glass Pad", 2); + const OFFER_MS = await openOnPerform(page, "Glass Pad", 3); await page.keyboard.down("a"); // Grown: B's first frame is the sound's face, its last is B's own place. + // The face is read where it stands once B is in the well beside it (the + // well makes room for B as it grows, a jump, before B grows from it). let n0 = (await anims(page)).length; - const name0 = await faceBox(page); await grow(page, OFFER_MS); + const name0 = await faceBox(page); await expect(page.locator(".pf-offer")).toHaveAttribute("data-moment", "grown"); let grown = (await anims(page)).slice(n0).find((a) => !/ghost/.test(a.cls)); expect(grown, "B grows").toBeTruthy(); @@ -135,20 +138,37 @@ test("an offer grows from the sound in hand, fills when taken, and folds back wh n0 = (await anims(page)).length; await page.locator(".pf-pad", { hasText: "Next" }).click(); await expect(page.locator("#toasts")).toContainText("Passed on B.", { timeout: 10_000 }); + // The copy folds a frame after the press, into the face as it stands once + // the well has reflowed (NEXT keeps B's room while the next grows). + const foldOf = (from) => expect.poll(async () => (await anims(page)).slice(from).find((a) => /ghost/.test(a.cls) && a.moment === "folded") || null, { timeout: 5_000 }).not.toBeNull(); + await foldOf(n0); const folded = (await anims(page)).slice(n0).find((a) => /ghost/.test(a.cls) && a.moment === "folded"); - expect(folded, "B folds back").toBeTruthy(); expect(folded.frames[0].transform).toBe("none"); expect(near(boxOf(folded.frames[folded.frames.length - 1].transform, folded.at), await faceBox(page)), "into the sound's face").toBe(true); + // Passed with PASS (heard): no next offer, so B's room goes and the face + // stands alone; the copy folds into the face where it stands then. + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: OFFER_MS }); + await peek(page, 1800); + n0 = (await anims(page)).length; + await page.locator(".pf-pad.pf-pass").click(); + await expect(page.locator("#toasts")).toContainText("Passed on B.", { timeout: 10_000 }); + await foldOf(n0); + const passed = (await anims(page)).slice(n0).find((a) => /ghost/.test(a.cls) && a.moment === "folded"); + await expect(page.locator(".pf-well")).not.toHaveClass(/\boffered\b/); + expect(near(boxOf(passed.frames[passed.frames.length - 1].transform, passed.at), await faceBox(page)), "PASS folds into the sound's face where it stands").toBe(true); + // Taken (heard): a copy of B fills green from its base, then goes into the // face, and the name is the offer's. - await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: OFFER_MS }); + await grow(page, OFFER_MS); await peek(page, 1800); n0 = (await anims(page)).length; const before = await page.locator(".pf-name").textContent(); - const nameAtTake = await faceBox(page); await page.locator(".pf-pad", { hasText: "Take" }).click(); await expect(page.locator(".pf-offer")).toHaveAttribute("data-moment", "taken"); + // Taken, B leaves the well and the face stands alone again: B goes into + // the face where it stands then. + const nameAtTake = await faceBox(page); const after = (await anims(page)).slice(n0); const fill = after.find((a) => /pf-ghost-fill/.test(a.cls)); expect(fill, "B fills").toBeTruthy(); diff --git a/tests/web/perform_open_early.spec.js b/tests/web/perform_open_early.spec.js index 5859ed29..9aee6ca4 100644 --- a/tests/web/perform_open_early.spec.js +++ b/tests/web/perform_open_early.spec.js @@ -111,7 +111,13 @@ test("a Keep while a patch is still opening is refused and says why, and the pre await page.evaluate(clickRow, "Acid Line"); await goLevel(page, "perform"); await expect(page.locator(".pf-name")).toHaveText("Acid Line"); - const keepPad = page.locator(".pf-pad", { hasText: /^Keep$/ }); + // Keep is in the moved bar, there once the sound has left home: a control + // turned on the patch PERFORM already plays (its shipped wiring) moves it. + const turned = page.locator('.pf-deck .pf-knob:not(.search):not(.pending):not(.half-hi)').first(); + await turned.focus(); + for (let i = 0; i < 8; i++) await page.keyboard.press("ArrowUp"); + const keepPad = page.locator(".pf-moved .pf-keep"); + await expect(page.locator(".pf-moved")).toHaveClass(/\bon\b/); await keepPad.click(); await expect.poll(() => page.evaluate(() => window.__toasts.join("\n"))).toContain("Keep waits for Acid Line to finish opening"); expect(await page.evaluate(() => window.__toasts.join("\n"))).not.toContain("Kept"); @@ -131,6 +137,12 @@ test("a Keep while a patch is still opening is refused and says why, and the pre await expect(page.locator(".pf-name")).toHaveText("Glass Pad"); // Now it has landed, Keep is Keep: the pad lights when it is made. (Its // toast can queue behind the open's own news, so the pad is what is read.) + // A control turned on Glass Pad moves it, and the bar offers Keep again. + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); + const turnedHere = page.locator('.pf-deck .pf-knob:not(.search):not(.pending):not(.half-hi)').first(); + await turnedHere.focus(); + for (let i = 0; i < 8; i++) await page.keyboard.press("ArrowUp"); + await expect(page.locator(".pf-moved")).toHaveClass(/\bon\b/); await keepPad.click(); await expect(keepPad).toHaveClass(/\bflash\b/, { timeout: 30_000 }); expect(await page.evaluate(() => window.__toasts.join("\n"))).not.toMatch(/Keep waits for Glass Pad/); diff --git a/tests/web/perform_palette.spec.js b/tests/web/perform_palette.spec.js index 31e281a2..2983402d 100644 --- a/tests/web/perform_palette.spec.js +++ b/tests/web/perform_palette.spec.js @@ -125,10 +125,12 @@ test("the palette places, hides and orders up to eight controls, and the panel c const places = pal.locator(".pp-place"); await expect(places).toHaveCount(10); for (let i = 0; i < 10; i++) await expect(places.nth(i)).toBeDisabled(); - // Blend and Wander stay at the end of the deck, after the eight. - await expect(page.locator(".pf-knob")).toHaveCount(10); - await expect(page.locator('.pf-knob[data-i="8"]')).toHaveAttribute("aria-label", "Blend"); - await expect(page.locator('.pf-knob[data-i="9"]')).toHaveAttribute("aria-label", "Wander"); + // Blend and Wander keep the slots after the eight (MIDI's and the keys'), + // drawn where they live: Blend under the faces in the well, Wander at the + // start of the pad row. + await expect(page.locator(".pf-deck .pf-knob")).toHaveCount(8); + await expect(page.locator('.pf-blend[data-i="8"] input')).toHaveAttribute("aria-label", "Blend"); + await expect(page.locator('.pf-pads .pf-knob[data-i="9"]')).toHaveAttribute("aria-label", "Wander"); // Hide one, and there is room again. await row(page, "Snap", true).locator(".pp-hide").click(); diff --git a/tests/web/perform_recentre.spec.js b/tests/web/perform_recentre.spec.js index 10f8f9c0..c82bf00c 100644 --- a/tests/web/perform_recentre.spec.js +++ b/tests/web/perform_recentre.spec.js @@ -82,7 +82,7 @@ test("a re-centred control glides home with a fading ghost, and a background re- window.__angles.push([performance.now(), m ? Number(m[1]) : NaN]); }).observe(turn, { attributes: true, attributeFilter: ["transform"] }); }); - await page.locator(".pf-pad", { hasText: "Keep" }).click(); + await page.locator(".pf-moved .pf-keep").click(); await expect(page.locator("#toasts")).toContainText("Kept: this is home now.", { timeout: 15_000 }); await expect(bright).toHaveAttribute("aria-valuenow", "0.00"); await page.waitForTimeout(600); @@ -122,7 +122,7 @@ test("a pot on Blend is let go when Blend comes home, and takes it again from ho await page.evaluate(() => window.__cc(20, 0)); await expect(page.locator("#midi-panel .midi-row").nth(6)).toContainText("CC 20"); await page.locator("#midi-ind").click(); - const blend = page.locator('.pf-knob[data-i="6"]'); + const blend = page.locator('.pf-blend[data-i="6"] input'); const at = async () => Number(await blend.getAttribute("aria-valuenow")); // The pot picks Blend up at home and turns it most of the way to the offer. for (const v of [2, 20, 45, 70, 95, 115]) await page.evaluate((v) => window.__cc(20, v), v); diff --git a/tests/web/perform_touch.spec.js b/tests/web/perform_touch.spec.js new file mode 100644 index 00000000..e3eede45 --- /dev/null +++ b/tests/web/perform_touch.spec.js @@ -0,0 +1,151 @@ +// PERFORM and EVOLVE under a finger (Plan-008 PR C1): every function the +// layout moved is a tap away on a touch screen, and no printed key shows. +// +// - Wander freezes on a tap; XY opens in the well, a tap on its field moves +// its two controls, and the moved bar's KEEP answers a tap; HOW IT WORKS and +// ARRANGE (with what velocity plays) open; stage mode says a tap plays and +// its × leaves. +// - EVOLVE's corner (⇄ circuit) and what each generation did answer a tap. +// - KEEP, BACK and the pill's × are a finger's size; BACK answers a tap. +// - PEEK held with a finger plays B (heard, so a pass counts it). +const { test, expect } = require("@playwright/test"); +const { goLevel, bankTab } = require("./shell"); +const budget = require("./perform_budget.js"); + +// A tablet: a coarse pointer and touch, wide enough to have no gate. +test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true, isMobile: true }); + +async function boot(page) { + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await budget.watch(page); + await page.addInitScript(() => { + try { + for (const k of ["auracle-played", "auracle-bench-tour", "auracle-bank-toured", "auracle-warmed"]) localStorage.setItem(k, "1"); + } catch (_) {} + }); + await page.goto("/"); + const anyway = page.locator("#hg-anyway"); + if (await anyway.isVisible().catch(() => false)) await anyway.click(); + await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 150_000 }); + await goLevel(page, "perform"); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); + return errors; +} + +test("on a touch screen every PERFORM function the layout moved is a tap away, with no printed keys", async ({ page }) => { + test.setTimeout(240_000); + const errors = await boot(page); + // Glass Pad, whose BRIGHT turns both ways: the XY pad's first axis moves. + await bankTab(page, "presets"); + await page.locator(".bank-item", { hasText: "Glass Pad" }).first().tap(); + await goLevel(page, "perform"); + await expect(page.locator(".pf-name")).toHaveText("Glass Pad", { timeout: 60_000 }); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); + // No printed key on a pad or in the moved bar under a finger. + const keyShown = await page.evaluate(() => ({ + pads: [...document.querySelectorAll(".pf-pad[data-key]")].some((b) => getComputedStyle(b, "::before").display !== "none"), + bar: [...document.querySelectorAll(".pf-mv[data-key]")].some((b) => getComputedStyle(b, "::after").display !== "none"), + })); + expect(keyShown, "no printed key on a pad or in the moved bar").toEqual({ pads: false, bar: false }); + // The first-steps pill's × is a finger's size. + const x = await page.locator("#guide .x").boundingBox(); + expect(Math.min(x.width, x.height), "the pill's ×").toBeGreaterThanOrEqual(40); + // Wander: a tap freezes it, another lets it go. + const wander = page.locator(".pf-pads .pf-wander"); + await wander.tap(); + await expect(wander).toHaveAttribute("data-frozen", "true"); + await wander.tap(); + await expect(wander).toHaveAttribute("data-frozen", "false"); + // XY in the well: a tap on its field moves its two controls, and the sound + // has moved, so the bar offers KEEP, which answers a tap. + await page.locator(".pf-xy-btn").tap(); + await expect(page.locator(".pf-well")).toHaveAttribute("data-mode", "xy"); + // A control that turns one way only stops at the center on the other, so + // the corners are tried until one moves the sound. + const f = await page.locator(".pf-xy-field").boundingBox(); + let corner = null; + for (const [fx, fy] of [[0.85, 0.15], [0.15, 0.85], [0.15, 0.15], [0.85, 0.85]]) { + await page.touchscreen.tap(f.x + f.width * fx, f.y + f.height * fy); + const moved = await expect(page.locator(".pf-moved.on")).toHaveCount(1, { timeout: 2_000 }).then(() => true, () => false); + if (moved) { + corner = [fx, fy]; + break; + } + } + await expect(page.locator(".pf-moved")).toHaveClass(/\bon\b/); + await page.locator(".pf-xy-btn").tap(); + await expect(page.locator(".pf-well")).toHaveAttribute("data-mode", "face"); + // KEEP and BACK are a finger's size, and BACK answers a tap: the sound + // glides home, and the bar goes. + for (const b of await page.locator(".pf-moved .pf-mv").all()) expect((await b.boundingBox()).height).toBeGreaterThanOrEqual(40); + await page.locator(".pf-moved .pf-back").tap(); + await expect(page.locator(".pf-moved")).not.toHaveClass(/\bon\b/, { timeout: 10_000 }); + // Moved again from the same corner, KEEP answers a tap. + await page.locator(".pf-xy-btn").tap(); + await page.touchscreen.tap(f.x + f.width * corner[0], f.y + f.height * corner[1]); + await expect(page.locator(".pf-moved")).toHaveClass(/\bon\b/); + await page.locator(".pf-xy-btn").tap(); + await page.locator(".pf-moved .pf-keep").tap(); + await expect(page.locator("#toasts")).toContainText("Kept: this is home now.", { timeout: 15_000 }); + // HOW IT WORKS opens in the well. + await page.locator(".pf-why-btn").tap(); + await expect(page.locator(".pf-why-body")).toBeVisible(); + await page.locator(".pf-why-x").tap(); + await expect(page.locator(".pf-why-body")).toBeHidden(); + // ARRANGE holds what velocity plays. + await page.locator(".pf-arrange").tap(); + await expect(page.locator(".pp .pp-vel #pf-touch-sel")).toBeVisible(); + await expect(page.locator(".pp .pp-vel #pf-touch-depth")).toBeVisible(); + await page.locator(".pp-x").tap(); + // Stage mode: a tap plays, and × leaves. + await page.locator(".pf-stage-btn").tap(); + await expect(page.locator(".st-stage")).toBeVisible(); + await expect(page.locator(".st-hint")).toHaveText("tap to play the sound · × leaves"); + await page.locator(".st-leave").tap(); + await expect(page.locator(".st-stage")).toHaveCount(0); + expect(errors).toEqual([]); +}); + +test("on a touch screen EVOLVE's corner and what each generation did answer a tap", async ({ page }) => { + test.setTimeout(240_000); + const errors = await boot(page); + await goLevel(page, "evolve"); + await expect(page.locator("#name-a")).not.toHaveText("sound a", { timeout: 90_000 }); + await page.locator("#flip-a").tap(); + await expect(page.locator("#mini-a")).toBeVisible(); + await page.locator("#flip-a").tap(); + await expect(page.locator("#face-a")).toBeVisible(); + await page.locator("#lineage-btn").tap(); + await expect(page.locator("#lineage-pop")).toBeVisible(); + await page.locator("#lineage-x").tap(); + await expect(page.locator("#lineage-pop")).toBeHidden(); + expect(errors).toEqual([]); +}); + +// A finger held on PEEK: the pad's press and its release, as a touch screen +// sends them (CDP touch events), with a note sounding. B was heard, so PASS +// counts it as a pick for what you had. +test("on a touch screen PEEK held with a finger plays B", { tag: "@slow" }, async ({ page }) => { + test.setTimeout(360_000); + const errors = await boot(page); + await bankTab(page, "presets"); + await page.locator(".bank-item", { hasText: "Glass Pad" }).first().tap(); + await goLevel(page, "perform"); + await expect(page.locator(".pf-name")).toHaveText("Glass Pad", { timeout: 60_000 }); + await expect(page.locator(".pf-status")).toContainText("controls reach", { timeout: 120_000 }); + const OFFER_MS = await budget.offerBudget(page, { waits: 1 }); + await page.locator(".pf-pad", { hasText: /^Offer$/ }).tap(); + await expect(page.locator(".pf-offer")).toHaveClass(/\bready\b/, { timeout: OFFER_MS }); + const peek = await page.locator(".pf-pad", { hasText: "Peek" }).boundingBox(); + const at = { x: Math.round(peek.x + peek.width / 2), y: Math.round(peek.y + peek.height / 2) }; + const cdp = await page.context().newCDPSession(page); + await page.keyboard.down("a"); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [at] }); + await page.waitForTimeout(1800); // held: B must sound for a second while a note does + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await page.keyboard.up("a"); + await page.locator(".pf-pad.pf-pass").tap(); + await expect(page.locator("#toasts")).toContainText("Passed on B. That counts as a pick for what you had."); + expect(errors).toEqual([]); +}); diff --git a/tests/web/perform_truth.spec.js b/tests/web/perform_truth.spec.js index ed9b07f6..b96c4b4a 100644 --- a/tests/web/perform_truth.spec.js +++ b/tests/web/perform_truth.spec.js @@ -214,16 +214,25 @@ test("first steps name a control that turns on this patch, and speak alone", asy // A newcomer, at PERFORM, where the app opens: step 1 says what the keybed // coach would, so the coach keeps quiet. In PATCH it is up until the first // note. - await expect(page.locator("#view-perform .pf-steps")).toBeVisible(); + await expect(page.locator("#guide .pf-steps")).toBeVisible(); await expect(page.locator(".coach")).toBeHidden(); await goLevel(page, "patch"); await expect(page.locator(".coach")).toBeVisible(); await openOnPerform(page, "Glass Pad"); - await expect(page.locator(".pf-steps")).toBeVisible(); + await expect(page.locator("#guide .pf-steps")).toBeVisible(); // One voice per lesson: step 1 says what the coach says. await expect(page.locator(".coach")).toBeHidden(); await wired(page); - const step2 = (await page.locator(".pf-step").nth(1).textContent()).trim(); + // Back in PATCH the coach speaks again (until the first note). + await goLevel(page, "patch"); + await expect(page.locator(".coach")).toBeVisible(); + await goLevel(page, "perform"); + // The pill shows one step at a time: a note played, step 2 is next (and + // the note retires the coach for good). + await expect(page.locator("#guide .pf-step.now")).toContainText("Play a key"); + await page.keyboard.press("a"); + await expect(page.locator("#guide .pf-step.now")).toContainText(/^Turn /); + const step2 = (await page.locator("#guide .pf-step.now").textContent()).trim(); const m = step2.match(/Turn ([A-Z]+): drag (up or down|up|down)$/); expect(m, step2).not.toBeNull(); const k = page.locator(".pf-knob", { has: page.locator(".pf-k-name", { hasText: new RegExp(`^${m[1]}$`, "i") }) }); @@ -231,9 +240,6 @@ test("first steps name a control that turns on this patch, and speak alone", asy const cls = await k.getAttribute("class"); expect(cls, `${m[1]} turns on this patch`).not.toMatch(/\b(search|pending|unwired)\b/); if (m[2] === "up or down") expect(cls).not.toMatch(/\bhalf-(lo|hi)\b/); - // Back in PATCH the coach speaks again. - await goLevel(page, "patch"); - await expect(page.locator(".coach")).toBeVisible(); expect(errs).toEqual([]); }); @@ -241,6 +247,7 @@ test("choosing an XY axis gives the note keys back", async ({ page }) => { test.setTimeout(300_000); const errs = await boot(page); await openOnPerform(page, "Glass Pad"); + await page.locator(".pf-xy-btn").click(); const ysel = page.locator(".pf-xy-head select").nth(1); // As a click does: the select has focus when the choice is made. await ysel.focus(); @@ -299,15 +306,17 @@ test("after a pass, Blend comes home", async ({ page }) => { const OFFER_MS = await budget.offerBudget(page, { waits: 1 }); await page.locator(".pf-pad", { hasText: "Offer" }).click(); await page.waitForSelector(".pf-offer.ready", { timeout: OFFER_MS }); - const blend = page.locator('.pf-knob[data-i="6"]'); - await drag(page, blend, -120); + // Blend, the slider under the two faces, toward B. + const blend = page.locator('.pf-blend[data-i="6"] input'); + await blend.focus(); + await page.keyboard.press("End"); expect(Number(await blend.getAttribute("aria-valuenow"))).toBeGreaterThan(0.5); // NEXT (Offer, while B holds one) is a pass: B empties, and Blend glides // home. await expect(page.locator(".pf-pad.primary")).toHaveText("Next"); await page.locator(".pf-pad", { hasText: "Next" }).click(); await expect(blend).toHaveAttribute("aria-valuenow", "0.00", { timeout: 2_000 }); - await expect(blend.locator(".pf-k-sub")).toHaveText(/^(no offer yet|0% offer)$/); + await expect(page.locator('.pf-blend[data-i="6"] .pf-blend-v')).toHaveText(/^(no offer yet|0% offer)$/); expect(errs).toEqual([]); }); @@ -335,7 +344,7 @@ test("a drift is not a new patch: the status never says listening, and its re-ch await wander.focus(); for (let i = 0; i < 20; i++) await page.keyboard.press("ArrowUp"); await expect(wander.locator(".pf-k-sub")).toHaveText(/^roam/); - await page.locator(".pf-xy-field").focus(); // off the dial, hands off + await page.evaluate(() => document.activeElement?.blur()); // off the dial, hands off // A drift arrives and glides. await page.waitForFunction(() => window.__wander.some((t) => /gliding/.test(t)), null, { timeout: DRIFT_MS }); // …and finishes: give the glide (3 s at most in roam) time to land. @@ -351,7 +360,7 @@ test("a drift is not a new patch: the status never says listening, and its re-ch const k0 = await page.evaluate(() => performance.now()); await wander.focus(); await page.keyboard.press("Home"); // Wander still - await page.locator(".pf-pad", { hasText: "Keep" }).click(); + await page.locator(".pf-moved .pf-keep").click(); await expect(page.locator("#toasts")).toContainText("Kept: this is home now. Back returns here.", { timeout: 10_000 }); await page.waitForTimeout(1500); const afterKeep = await page.evaluate((t) => window.__pfPosts.filter((p) => p.type === "perform_wire" && p.t > t && !p.bg), k0); diff --git a/tests/web/perform_wander.spec.js b/tests/web/perform_wander.spec.js index 2f9ae7ec..85332d01 100644 --- a/tests/web/perform_wander.spec.js +++ b/tests/web/perform_wander.spec.js @@ -114,10 +114,10 @@ test("Wander answers a second and a half after it is let go, and says what it is await expect(sub).toHaveText(/^paused [1-4] s$/, { timeout: 2_000 }); await expect(sub).not.toHaveText(/^paused/, { timeout: 8_000 }); - // Frozen. - await page.locator(".pf-pad", { hasText: "Freeze" }).click(); + // Frozen: a tap on Wander (the FREEZE pad went into it, Plan-008 C1). + await wander.click(); await expect(sub).toHaveText("frozen"); - await page.locator(".pf-pad", { hasText: "Freeze" }).click(); + await wander.click(); const statuses = await page.evaluate(() => window.__statuses); expect(statuses.filter((t) => /wander|paused|drifting|gliding|walking/.test(t)), "the status line keeps to the patch").toEqual([]); diff --git a/tests/web/responsive.spec.js b/tests/web/responsive.spec.js index df326a5c..1208dbd0 100644 --- a/tests/web/responsive.spec.js +++ b/tests/web/responsive.spec.js @@ -174,16 +174,18 @@ test("Take keeps the controls live, names the taken offer, and brings Blend home const reachBefore = await page.evaluate(() => [0, 1, 2, 3, 4, 5].filter((i) => !document.querySelector(`.pf-knob[data-i="${i}"]`).classList.contains("unwired")).length); await page.locator(".pf-pad", { hasText: "Offer" }).click(); await page.waitForSelector(".pf-offer.ready", { timeout: 120_000 }); - // Blend toward the offer, as a player auditions it. - const b = await page.locator(".pf-knob[data-i='6']").boundingBox(); - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); + // Blend toward the offer, as a player auditions it: the slider under the + // two faces, dragged most of the way to B. + const blend = page.locator(".pf-blend[data-i='6'] input"); + const b = await blend.boundingBox(); + await page.mouse.move(b.x + 2, b.y + b.height / 2); await page.mouse.down(); - for (let i = 1; i <= 10; i++) await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2 - i * 12); + for (let i = 1; i <= 10; i++) await page.mouse.move(b.x + 2 + (i * (b.width - 4)) / 12, b.y + b.height / 2); await page.mouse.up(); - expect(Number(await page.locator(".pf-knob[data-i='6']").getAttribute("aria-valuenow"))).toBeGreaterThan(0.3); + expect(Number(await blend.getAttribute("aria-valuenow"))).toBeGreaterThan(0.3); await page.locator(".pf-pad", { hasText: "Take" }).click(); // Blend is home at once: B is empty. - await expect(page.locator(".pf-knob[data-i='6']")).toHaveAttribute("aria-valuenow", "0.00"); + await expect(blend).toHaveAttribute("aria-valuenow", "0.00"); // The taken patch is named for what it is. await expect(page.locator(".pf-name")).toHaveText("Glass Pad (taken offer)", { timeout: 30_000 }); // The controls did not all go dark while the offer is re-measured: some diff --git a/tests/web/space_after_a_click.spec.js b/tests/web/space_after_a_click.spec.js index 084647da..8583366f 100644 --- a/tests/web/space_after_a_click.spec.js +++ b/tests/web/space_after_a_click.spec.js @@ -193,7 +193,7 @@ test("in PERFORM, Space plays after a drag on a control, a click on the XY pad, await goLevel(page, "perform"); await expect(page.locator("#view-perform")).toBeVisible(); - // A named control, dragged a little and let go: it keeps the focus. + // A named control, dragged a little and let go (a pointer's turn leaves no focus, so ↵ is KEEP). const ctl = page.locator("#view-perform .pf-knob[role=slider]").first(); const box = await ctl.boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); @@ -203,19 +203,20 @@ test("in PERFORM, Space plays after a drag on a control, a click on the XY pad, console.log(`[space_after_a_click] a PERFORM control dragged: focus on ${await focused(page)}`); await spacePlays(page, "after a drag on a PERFORM control"); - // The XY pad, clicked. + // The XY pad (a mode of the well, from its corner), clicked. + await page.locator("#view-perform .pf-xy-btn").click(); const xy = page.locator("#view-perform .pf-xy-field"); const xbox = await xy.boundingBox(); await page.mouse.click(xbox.x + xbox.width * 0.6, xbox.y + xbox.height * 0.4); console.log(`[space_after_a_click] the XY pad clicked: focus on ${await focused(page)}`); await spacePlays(page, "after a click on the XY pad"); - // A pad: FREEZE, clicked, stays frozen through Space. - const freeze = page.locator(".pf-pad", { hasText: "Freeze" }); - await freeze.click(); - await expect(freeze).toHaveAttribute("aria-pressed", "true"); - await spacePlays(page, "after a click on FREEZE"); - await expect(freeze, "Space did not press FREEZE again").toHaveAttribute("aria-pressed", "true"); + // Freeze: a tap on Wander, which stays frozen through Space. + const wander = page.locator("#view-perform .pf-wander"); + await wander.click(); + await expect(wander).toHaveAttribute("data-frozen", "true"); + await spacePlays(page, "after a tap on Wander"); + await expect(wander, "Space did not tap Wander again").toHaveAttribute("data-frozen", "true"); expect(errors).toEqual([]); }); diff --git a/tests/web/text_fits.spec.js b/tests/web/text_fits.spec.js index 7aed687b..57cdb3d0 100644 --- a/tests/web/text_fits.spec.js +++ b/tests/web/text_fits.spec.js @@ -83,7 +83,7 @@ for (const [width, height] of [[1000, 800], [1280, 800]]) { await page.goto("/"); await expect(page.locator("#boot")).toHaveClass(/\bdone\b/, { timeout: 180_000 }); await goLevel(page, "perform"); - await expect(page.locator(".pf-knob .pf-k-sub")).toHaveCount(8); + await expect(page.locator(".pf-knob .pf-k-sub")).toHaveCount(7); // the six and Wander (Blend is a slider in the well) await expect(page.locator(".pf-deck")).toBeVisible(); // Every caption state in every column, measured in the same task, so // nothing repaints between the write and the read. Twice: as this diff --git a/tests/web/type_scale.spec.js b/tests/web/type_scale.spec.js index bdd1f183..a0c4d7aa 100644 --- a/tests/web/type_scale.spec.js +++ b/tests/web/type_scale.spec.js @@ -132,9 +132,9 @@ test("a canvas draws its text at the canvas floor, 12 px, or larger, and the for ([c, s]) => window.__pwCanvasText.some((t) => (!c || c.includes(t.canvas)) && t.text.startsWith(s)), [canvas, text], { timeout: 90_000 }, ); - // EVOLVE's scopes label full scale once a pair's sound is in. - await openView(page, "evolve"); - await drawn(["scope-a", "scope-b"], "0 dBFS"); + // (EVOLVE's cards draw faces now, not labelled scopes: Plan-008 C1. The + // commit pair's scopes still label full scale; they are drawn only in a + // commit, which this boot doesn't reach.) // TASTE's map, and LEARNING with forecasts to draw, handed over as the // worker hands them. await openView(page, "taste"); diff --git a/www/brand/voice.md b/www/brand/voice.md index e03c3138..8862ae17 100644 --- a/www/brand/voice.md +++ b/www/brand/voice.md @@ -449,7 +449,8 @@ banned-words check in `make dev-check` reads the list after it. | **TAUGHT n** | The counter: everything it learned from (picks, stars, cuts) | PICKS for a count that includes stars | | **save / saved** | Kept safe from replacement, on the Saved shelf | "saved" for a download | | **download / open** | Files | save, export, load for files | -| **keep** | PERFORM's Keep pad, only | every other keep | +| **keep** | PERFORM's Keep, in the moved bar (↵), only | every other keep | +| **moved · KEEP · BACK** | PERFORM's head, shown exactly when the sound has left home (a control turned, a drift not kept, a glide) | changed, dirty, edited | | **keep as new** | PATCH: the edit joins the pool as a new sound, and the original stays | commit | | **pick the edit** | KEEP AS NEW's one-time shortcut: keep the edit as new without the comparison, telling the model you'd pick it | my edit is better | | **bank** | The rail and its tabs: Pool, Saved, Presets | "IN BANK", "the node bank" | @@ -463,6 +464,22 @@ banned-words check in `make dev-check` reads the list after it. | **ideas** | Wander's middle zone | "offer" as a zone name | | **the sound you're playing** | In help and tooltips; toasts name it | bench, workbench, current patch, #ids | | **another pair** | Deal a new pair without picking | skip, in a pair | +| **PASS** | PERFORM's pad: pass on the offer in B without growing another; heard, a pick for what you had, with UNDO | skip, reject, discard | +| **first steps** | PERFORM's first-visit steps, one at a time in a pill bottom left; its × is *Stop showing these* | guide (that is the site), tutorial, tips, coach (that is the keybed's) | +| **▶ PLAY** | An EVOLVE card's button: its phrase | SAMPLE, audition | +| **↓ patch** | An EVOLVE card's corner: open its sound in PATCH without picking | open in patch, promote | +| **⇄ face** | An EVOLVE card's corner, flipped to its circuit: back to its face | wave | +| **Velocity plays**, **vel** | ARRANGE's row for which control a note's velocity drives, and the tick on that control | TOUCH, velo | +| **share** | PERFORM's button by the name: the sound's card as a picture | export, send | +| **what each generation did** | EVOLVE's disclosure under EVOLVE POOL, opening the lineage's spark and log | history, log (as a label) | +| **frozen** | Wander's state while a tap (or Enter) holds it still: its ring lit, this word under it and as its value | paused (that is hands on), held, stopped | +| **Nothing to keep: this sound is home.** / **Nothing to go back to: this sound is home.** | ↵ or ⇧⌫ pressed with the sound at home: the refusal, said at once | No changes, Already saved | +| **B · ‹aim›** (B · GRITTIER) | B's label for an offer a control asked for: B and the aim word of the way it was turned | B (grittier), target | +| **offered · hold B to peek** / **hold Peek to hear it** (touch) | B's line under its face: it is an offer, and how to hear it | press and hold, preview | +| **depth** | ARRANGE's slider beside Velocity plays: how far velocity reaches | amount, intensity, sensitivity | +| **CONTROLS** | PERFORM's cap over the controls, with ARRANGE and HOW IT WORKS | knobs, macros, deck | +| **Share this sound as a picture** | The share button's accessible name | export image, save image | +| **or** | The word between EVOLVE's two cards, a silk label | vs, versus | | **listening…** | PERFORM measuring a sound's controls | measuring… (that is a figure's) | | **measuring…** | Renders on their way for an answer or the lesson: a figure, or the lesson's shape, while the engine renders and measures the sound in hand | listening… (that is PERFORM's), loading… | | **rating…** | The model rating an edited sound again, on the guess above the rack | re-measuring…, listening… (that is PERFORM's) | diff --git a/www/docs/src/accessibility.md b/www/docs/src/accessibility.md index 74334689..ecedb350 100644 --- a/www/docs/src/accessibility.md +++ b/www/docs/src/accessibility.md @@ -35,6 +35,23 @@ rules; **MODEL** in the menu bar is a button whose Enter or tag, saying what the model believes and from how many picks, is a status, read once as the view comes up. +**PERFORM.** Its pads have keys, printed on each and given in +`aria-keyshortcuts`: n OFFER (NEXT), hold b PEEK, +⇧↵ TAKE, and, once the sound has moved, ↵ KEEP with +nothing focused (a dial or BLEND turned with the mouse or a finger lets go of +the focus; one reached with Tab keeps it, and its Enter) and +⇧⌫ BACK. They yield to a text field and a +modal dialog. Each named control and WANDER is a slider (arrows turn it, +Home centers it, Enter on a named control plays its +sweep and on WANDER freezes it); WANDER’s value reads *frozen* while it is, +since a slider cannot also be a toggle. BLEND is a native slider under the +faces while B holds an offer. **XY** in the well’s corner is a toggle button +(`aria-pressed`), and **HOW IT WORKS** a disclosure (`aria-expanded`); each +fills the well, and Esc inside it hands focus back to the button. +The moved bar’s KEEP and BACK leave the tab order while the sound is home. +The first-steps pill’s step is a status, read once as each new step comes up, and +its **×** is a button named *Stop showing these*. + To place a module without a mouse: 1. Press Tab until you reach the module rail. diff --git a/www/docs/src/bank.md b/www/docs/src/bank.md index 05ff652f..dbcfda42 100644 --- a/www/docs/src/bank.md +++ b/www/docs/src/bank.md @@ -81,7 +81,7 @@ row’s end. bred children the pool didn’t keep, a line under the group counts them (*2 more were bred and rated below the pool.*). - **The green dot** after the glyph marks a child you haven’t heard yet. It - goes when its phrase plays (its **▶**, a pair’s **▶ SAMPLE**, + goes when its phrase plays (its **▶**, a pair’s **▶ PLAY**, [Compare](#compare-a-sound-beside-its-seed), or Space while it’s the sound you’re playing) or you play a note on it, and it is remembered across a reload until then. Playing an edit of it doesn’t count: that’s @@ -157,7 +157,7 @@ beside the bank: - **What the model rated each when it bred them**, in its own words: *when it bred them, it rated Soft Pad 60% · leaning and Warm Drone 2 52% · a hunch*. A child rated well below its seed is marked *exploring*, as the - [EVOLUTION strip](./views/evolve.md#the-evolution-strip) marks it. + [EVOLUTION](./views/evolve.md#what-each-generation-did) marks it. - **▶ for each**, the seed and the child, while both are in the pool. A seed a generation has replaced is only a name (*Soft Pad · replaced*): its sound was dropped, so there is nothing to play. diff --git a/www/docs/src/getting-started/first-session.md b/www/docs/src/getting-started/first-session.md index 8a253206..57242cf3 100644 --- a/www/docs/src/getting-started/first-session.md +++ b/www/docs/src/getting-started/first-session.md @@ -63,6 +63,11 @@ under your fingers.* Your three are **saved**, so no generation will replace them. They take 3 of your 10 save slots; release any of them from its row if you want the room. +At the bottom left, a pill shows the first things to try in PERFORM, +one step at a time: *Play a key*, then a control to turn on this sound, then +*Press OFFER*. Each ticks off (a pip fills) when you do it, not when you read +it, and **×** stops it showing. It is the one place first steps are shown. + ```admonish info collapsible=true title="How it works: why three picks are worth 18" Each of your three picks is recorded as a pick over each of the six you passed over: three times six. These picks fade with time like any other, so the warm @@ -87,19 +92,19 @@ it. Click a stop to go there, or press ⌥← for EVOLVE, you’re at.
+
+
PERFORM is for playing a sound. It gives you up to eight -controls named for what you hear, chosen from a palette of eighteen, BLEND -and WANDER, and six pads, and nothing in it stops to ask you a question.
+PERFORM is for playing a sound. It shows the sound large, in +a well, and gives you up to eight controls named for what you hear, chosen +from a palette of eighteen, WANDER and four pads, and nothing in it stops to +ask you a question.
@@ -18,54 +19,73 @@ every view shares. the keybed. 2. Turn a named control: drag it up or down. Double-click it to go back to the center. -3. Press **OFFER**, then hold **PEEK** to hear the variant it grew, or press - **TAKE** to make it yours. -4. Press **KEEP** when you like where you are. **BACK** returns there. -5. Turn **WANDER** up, and let the sound move on its own. - -Until you have done the first three once, a strip under the header lays them -out and ticks each off as you do it. Its second step names a control that -turns on this sound (*Turn BRIGHT: drag up or down*), never one it can’t -reach. +3. Press **OFFER** (N), then hold **PEEK** (or hold B) to + hear the variant it grew beside your sound, or press **TAKE** + (⇧↵) to make it yours. +4. Once the sound has moved, *moved · KEEP · BACK* shows by its name. Press + **KEEP** (↵) when you like where you are; **BACK** + (⇧⌫) returns there. +5. Turn **WANDER**, at the start of the pads, up, and let the sound move on + its own. + +Until you have done the first three once, a pill at the bottom left +shows them one at a time, with a pip for each, and ticks each off as you do +it. Its second step names a control that turns on this sound (*Turn BRIGHT: +drag up or down*), never one it can’t reach. Its **×** stops it showing. ## What you see -
-
-From the top:
-
-- **The header.** The sound’s [face](../faces.md), its name, a status line,
- **ARRANGE**, **STAGE ⇧F**, and a small scope. The face is the sound as it
- was last rendered, so turning a control doesn’t change it; keeping or taking
- does. The status line says what PERFORM is doing with the
- sound: *listening to this sound…* while it measures a new one, then *4 of 6
- controls reach this patch*, with *re-checking* after it while it measures
- again in the background, or *listening to Bite…* while it measures a
- control you just placed.
-- **Your controls in a row:** to start, **BRIGHT**, **SNAP**, **MOTION**,
- **BODY**, **GRIT**, and **SPACE**, then **BLEND** and **WANDER**. **ARRANGE**
- changes which sit here and in what order (see [the
- palette](#the-palette)). The line under each says what it turns, or what
- state it’s in.
-- **TOUCH.** What your playing velocity drives: *loudness only*, or one of
- the named controls (*bright: soft dark, hard bright*), with a slider for how
- far it reaches. The note beside it names the knobs velocity moves.
-- **Six pads:** **KEEP**, **BACK**, **OFFER**, **TAKE**, **PEEK**, and
- **FREEZE**.
-- **The B strip.** One line, labeled **B**, that says whether an offer is
- waiting and where it came from, with the offer’s own face once it has
- grown: *no offer: press Offer to grow a variant
- from here* until there is one.
-- **XY.** Two named controls under one finger.
+
+
+On the left, the sound:
+
+- **The head.** Its family and *in hand* (*BASS · IN HAND*; the family only
+ for a sound opened from the library and not edited since: a bred or edited
+ sound has none, so it reads *IN HAND*), its name, large, with **share**
+ beside it, and its blurb where it has one. Share opens **Download a
+ picture** on *the sound’s card*: its face, its name and its patch in one
+ picture. When the sound has moved from home, *moved · KEEP · BACK* shows at
+ the end of the first line (see [the pads](#the-pads)).
+- **The well.** The sound’s [face](../faces.md), large, standing on a floor
+ that reflects it, as stage mode draws it. The face is the sound as it was
+ last rendered, so turning a control doesn’t change it; keeping or taking
+ does. Click it to play the phrase, as Space does. While B holds
+ an offer, B’s face stands beside it in amber, with **BLEND** under the two
+ (see [Blend, Peek, and B](#blend-peek-and-b)); a click on B’s face plays
+ nothing, since B is heard with **PEEK** or by holding B. In its corner: **XY** (see
+ [the XY pad](#the-xy-pad)) and **⇧F** for [stage mode](#stage-mode); in
+ the top left, a small trace of what is sounding now.
+
+On the right, what you turn:
+
+- **CONTROLS**, with **ARRANGE** (see [the palette](#the-palette)) and
+ **HOW IT WORKS** (see [How it works](#how-it-works)), then a status line
+ saying what PERFORM is doing with the sound: *listening to this sound…*
+ while it measures a new one, then *4 of 6 controls reach this patch*, with
+ *re-checking* after it while it measures again in the background, or
+ *listening to Bite…* while it measures a control you just placed.
+- **Your controls**, three to a row: to start, **BRIGHT**, **SNAP**,
+ **MOTION**, **BODY**, **GRIT**, and **SPACE** (a longer panel takes four to
+ a row). The line under each says what it turns, or what state it’s in. A
+ small *vel* on one says your velocity plays it (see [what velocity
+ plays](#what-velocity-plays)).
- **UNDER THE HOOD.** The patch’s own knobs the controls and Wander are
turning right now, each a bar with its value in its own units.
-- **HOW IT WORKS.** Every control on the panel and what it does on this
- sound (see [How it works](#how-it-works)), then a short version of this
- page. Closed at rest.
+- **The pads:** **WANDER**, ringed in amber, then **OFFER**, **PEEK**,
+ **TAKE** and **PASS**, each with its key. On a window under about 1360
+ pixels wide, WANDER takes a line of its own above the four.
**Show measurements** in the **⋯** menu adds the numbers behind each control
to its tooltip: purity, reach in σ, the measured halves, and the knob gains.
+### What velocity plays
+
+**ARRANGE** has a row, **Velocity plays**: what your playing velocity drives,
+*loudness only* or one of the named controls that turns both ways on this
+sound (*bright: soft dark, hard bright*), with **depth**, a slider for how far
+it reaches, and a note naming the knobs velocity moves. The control it plays
+carries a small *vel* in its corner.
+
## The named controls
The panel starts with six:
@@ -115,8 +135,7 @@ top.
1. Press **+** on a control to place it on the panel, at the end. Up to eight
sit there at once; with eight placed, **+** waits until you hide one.
2. Press **×** to hide one. One always stays.
-3. Press **↑** or **↓** to move one earlier or later. BLEND and WANDER stay
- at the end.
+3. Press **↑** or **↓** to move one earlier or later.
The panel is kept with your session, through a reload. Press Esc
or **×** at the top to put the palette away. It is a panel, not a dialog: the
@@ -139,7 +158,7 @@ Hiding a control you have turned leaves the sound where it is: what it was
doing stays in the knobs, and the controls you keep turn on from there.
**BACK** returns to the last sound you kept, from before it; ⌘Z doesn’t,
because hiding a control is not an edit of the patch. A mod wheel or
-pressure held on the control you hide is let go. If **TOUCH** was playing
+pressure held on the control you hide is let go. If velocity was playing
it, velocity moves to the first control left that turns both ways, or to
loudness only, and a note says which. Reordering costs nothing at all: the
same controls are the same measurement.
@@ -386,8 +405,10 @@ get smoother, and one with no tail can’t get closer.
## How it works
To see what one control does to this sound, [ask it](#ask-what-it-does).
-**HOW IT WORKS**, below the hood, opens on the control you last touched, with
-every control on the panel a tap away. For each, it says what you hear it do
+**HOW IT WORKS**, in CONTROLS’ head, opens in the well over the dimmed face,
+on the control you last touched, with every control on the panel a tap away;
+the controls stay in reach beside it. **×**, Esc or HOW IT WORKS
+again puts the face back. For each, it says what you hear it do
(*A soft attack and few harmonics.*), what it listens to, and what it does on
this sound, from its measurement: the knobs it turns (*On this sound it turns
amp attack and filter cutoff.*), that nothing here turns it, or that it hasn’t
@@ -411,8 +432,11 @@ pulsing where it is.
## The XY pad
-**XY** puts two named controls under one finger. It starts as BRIGHT across
-and MOTION up, and either axis can be any of the six.
+**XY**, in the well’s corner, puts two named controls under one finger: the
+well becomes the pad, with the face dimmed behind it, the axis drop-downs
+along its top and each control’s end words at its edges. **XY** again, or
+Esc inside it, puts the face back. It starts as BRIGHT across and
+MOTION up, and either axis can be any control on the panel.
1. Choose an axis from its drop-down. The note keys play straight away after,
so a note letter can’t change the axis.
@@ -426,16 +450,24 @@ end words are struck through, and an axis not measured yet is dimmed, with
*listening to this sound…*. The pad has no “turn to ask” gesture; use the dial
for that.
-## Blend, Peek, and the B slot
+## Blend, Peek, and B
An offer is a second sound in a second set of voices that follows the same
hands: every note you play sounds on both. You hear B by crossfading to it,
never by a jump.
-- **BLEND** sets the mix, from *home* at the left to *offer* at the right.
-- **PEEK** plays B alone for as long as you hold it. Let go, and the mix
- returns to wherever BLEND is.
-- **TAKE** makes the offer your sound.
+B stands in the well beside your sound: its label (**B**, or **B · GRITTIER**
+for an [aimed offer](#amber-dashed-search-controls)), its face in amber, and
+under its floor what changed, then where it grew from and how to hear it
+(*grown toward your taste · offered · hold B to peek*). While an offer grows,
+B says so where its face will be (*growing an offer…*). With nothing in B,
+your sound stands alone.
+
+- **BLEND**, the slider under the two faces while B holds an offer, sets the
+ mix, from *home* at the left to *B* at the right.
+- **PEEK** plays B alone for as long as you hold it, or hold B.
+ Let go, and the mix returns to wherever BLEND is.
+- **TAKE** (⇧↵) makes the offer your sound.
The named controls turn the sound you’re on, not the offer.
@@ -477,7 +509,7 @@ The line under WANDER says what it is doing:
- *drift · gliding* while the knobs glide;
- *ideas · one in B* while B holds an offer;
- *paused 3 s* while your hands are on other controls;
-- *frozen* while **FREEZE** stops it;
+- *frozen* while it is frozen;
- *nothing better nearby* when the walk finds nothing it prefers, and stays.
Wander runs only while PERFORM is on screen.
@@ -492,20 +524,40 @@ down. A touch mid-glide stops the glide where it is, and the sound stays
there. Turning WANDER itself is not a touch: it waits while you turn it, and
answers once you let go.
-**Tap to freeze.** A short tap on WANDER (or the **FREEZE** pad) stops it
-where it is, and the line reads *frozen*. Tap again to release it.
+**Tap to freeze.** A short tap on WANDER stops it where it is: its ring
+lights, and the line reads *frozen*. Tap again to release it. With WANDER
+focused, Enter does the same. Freeze has no key of its own.
## The pads
-| Pad | What it does |
-|---|---|
-| **KEEP** | Makes the sound you hear home: *Kept: this is home now. Back returns here.* The controls’ positions are written into the patch as one undo step, so PATCH shows it, and the controls re-center on it |
-| **BACK** | Glides back to home: the last sound you kept or opened |
-| **OFFER** | Grows a variant from here into B. The first is usually there at once, grown ahead once the sound has been steady for a few seconds and your hands have been off it for two |
-| **NEXT** | What OFFER reads while B holds an offer, with *passes on B* under it. It passes on B (B empties, BLEND glides home) and brings the next, which has been growing meanwhile |
-| **TAKE** | Makes the offer in B your sound. It becomes home, with *(taken offer)* after its name, and BLEND returns home. The controls play on while the taken sound is measured, and the status line says *re-checking* until it is |
-| **PEEK** | Hold to hear the offer alone |
-| **FREEZE** | Stops Wander where it is, and its line reads *frozen*. The same as tapping the WANDER dial; not **HOLD** in the keys bar’s **KEYS ⋯**, which latches notes |
+| Pad | Key | What it does |
+|---|---|---|
+| **WANDER** | | How alive the sound is (see [Wander](#wander)); a tap freezes it. Not **HOLD** in the keys bar’s **KEYS ⋯**, which latches notes |
+| **OFFER** | N | Grows a variant from here into B. The first is usually there at once, grown ahead once the sound has been steady for a few seconds and your hands have been off it for two |
+| **NEXT** | N | What OFFER reads while B holds an offer, with *passes on B* under it. It passes on B (B empties, BLEND glides home) and brings the next, which has been growing meanwhile |
+| **PEEK** | hold B | Hold to hear the offer alone |
+| **TAKE** | ⇧↵ | Makes the offer in B your sound. It becomes home, with *(taken offer)* after its name, and BLEND returns home. The controls play on while the taken sound is measured, and the status line says *re-checking* until it is |
+| **PASS** | | Passes on B without growing another: B empties and BLEND glides home. A pass as NEXT’s is, with the same UNDO |
+
+PEEK, TAKE and PASS wait, dashed, with *needs an offer* under them, until B
+holds one.
+
+**Moved · KEEP · BACK.** When the sound has left home (a control turned, a
+drift not kept, a glide under way), *moved · KEEP · BACK* shows at the end of
+the head’s first line, in a place it always keeps, so nothing moves when it
+comes or goes.
+
+| | Key | What it does |
+|---|---|---|
+| **KEEP** | ↵, with nothing focused | Makes the sound you hear home: *Kept: this is home now. Back returns here.* The controls’ positions are written into the patch as one undo step, so PATCH shows it, and the controls re-center on it |
+| **BACK** | ⇧⌫ | Glides back to home: the last sound you kept or opened |
+
+With a control you reached with Tab focused, ↵ is that
+control’s (a button presses, a named control plays its sweep), not KEEP. A
+turn with the mouse or a finger leaves nothing focused, so ↵ keeps
+straight after it. With the sound at home, either key
+says there is nothing to keep or go back to. None of the pad keys act while
+you type in a field or a dialog is open ([the key map](../keyboard.md#in-perform)).
KEEP changes the sound you’re playing, not the bank. To keep the result as a
sound of its own, press **KEEP AS NEW** in [PATCH](./play.md). Until a sound
@@ -526,9 +578,11 @@ app asks the engine again.
## Stage mode
-In PERFORM, ⇧F, or **STAGE ⇧F** in the header, puts the sound
-you’re playing on the whole screen, for a gig or a stream: its name, its
-[face](../faces.md), large, standing on a floor that reflects it, and over the
+In PERFORM, ⇧F, or **⇧F** in the well’s corner, puts the sound
+you’re playing on the whole screen, for a gig or a stream: the wordmark and
+the model’s lamp at the top left, × at the top right, its name and *family ·
+in hand* at the bottom left (the family only where it has one), the keys along
+the foot, and its [face](../faces.md), large, standing on a floor that reflects it, and over the
face what you hear, drawn in the face’s own bands against the same bank, with
100 Hz, 1 kHz and 10 kHz marked beside it. The face stands whether or not
anything sounds; what you play lights the face’s outline and draws its own
@@ -550,7 +604,7 @@ plays it harder). F on its own is still a note, and in every other view
Nothing reachable from PERFORM opens a modal. A player in the middle of a
phrase can’t answer a question, so everything PERFORM needs to tell you arrives
-as a line: the status line, the B strip, or a toast. Everything it offers you
+as a line: the status line, B’s words in the well, or a toast. Everything it offers you
is a pad you can ignore.
## Before you have taught it anything
@@ -565,7 +619,7 @@ drift · gliding · no taste yet
grown before it has learned your taste
```
-The first is the line under WANDER, the second the B strip. Once the model has
+The first is the line under WANDER, the second B’s words in the well. Once the model has
been fitted, the first loses *· no taste yet*, and the second reads *grown
toward your taste*.
@@ -578,7 +632,7 @@ asks, without stopping the music. Once you have heard B, your answer counts:
| You | It records |
|---|---|
| Press **TAKE** | B over what you had: *Took B. That counts as a pick over what you had.* |
-| Press **NEXT** | What you had over B: *Passed on B. That counts as a pick for what you had.* |
+| Press **NEXT** or **PASS** | What you had over B: *Passed on B. That counts as a pick for what you had.* |
Heard means PEEK held, or BLEND past half, for at least a second while notes
were sounding. An offer you answer without hearing it teaches nothing, and a
@@ -604,9 +658,9 @@ less reliable than dealt ones, that is where it will show.
## MIDI
Plug in a controller and turn its first eight knobs: they take the first eight
-controls on PERFORM’s deck, in the order you turn them. A slot is a place on
-the deck, so after you rearrange the panel the same knob turns whatever is in
-its place now. Channel pressure brightens, and the mod wheel drives MOTION,
+of PERFORM’s slots, in the order you turn them: the panel’s controls in its
+order, then BLEND and WANDER. A slot is a place in that order, so after you
+rearrange the panel the same knob turns whatever is in its place now. Channel pressure brightens, and the mod wheel drives MOTION,
wherever they sit on the panel; while one is off it, its source adds
nothing. [MIDI](../keyboard.md#midi) has the whole map, including
learn, endless encoders, and soft takeover.
diff --git a/www/docs/src/views/play.md b/www/docs/src/views/play.md
index 339a9cfe..118d6a50 100644
--- a/www/docs/src/views/play.md
+++ b/www/docs/src/views/play.md
@@ -26,7 +26,7 @@ live while you edit it.
original stays as it was, and the new sound is safe until it has been in a
pick ([the bank](../bank.md#a-sound-you-keep-as-new-is-safe-until-its-been-in-a-pick)).
-You can also open a sound with **OPEN IN PATCH** on either side of a pair in
+You can also open a sound with **↓ PATCH** on either side of a pair in
[EVOLVE](./evolve.md), or by clicking a sound on the [TASTE map](./taste.md#a-sounds-card).
## What you see
diff --git a/www/landing/assets/screens/evolve.webp b/www/landing/assets/screens/evolve.webp
index 1d47ab5c..929cb79d 100644
Binary files a/www/landing/assets/screens/evolve.webp and b/www/landing/assets/screens/evolve.webp differ
diff --git a/www/landing/assets/screens/perform.webp b/www/landing/assets/screens/perform.webp
index d0edcc1c..05e3f7c0 100644
Binary files a/www/landing/assets/screens/perform.webp and b/www/landing/assets/screens/perform.webp differ
diff --git a/www/landing/index.html b/www/landing/index.html
index 795f300f..497cd5ab 100644
--- a/www/landing/index.html
+++ b/www/landing/index.html
@@ -213,7 +213,7 @@
+
PERFORM. Play the sound with controls named for what you hear, from BRIGHT to SPACE. Each one turns the few knobs that move this sound most in its direction. Press OFFER, and it grows a variant into B for @@ -231,10 +231,10 @@
+
EVOLVE. Pick between two sounds, and a meter - counts the picks until it next redraws your taste. Press EVOLVE POOL, and the log - says what each generation changed, knob by knob and module by module.
+ counts the picks until it next redraws your taste. Press EVOLVE POOL, and what + each generation did says what it changed, knob by knob and module by module.