From 8e790b4a957a7430ebd573a6554ee457bb39c437 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:50:51 -0700 Subject: [PATCH] feat(dial): snap to the closest altitude on drag release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag is a way of grabbing the altitude dial, not a free-standing encoder detent. Until now onDialUp held the live blend wherever the knob stopped (the "no auto-complete" model), so a grab-and-release could leave the experience parked mid-morph between two altitudes. Now a real turn (moved >= 6) auto-scrubs the short way to Math.round(pos) and settles there (frac 0 locks) — a grab-and-release always lands on a discrete altitude, exactly like a tap or label click. The tap path (moved < 6 -> jumpToScale) is unchanged, and reduced motion is handled by autoScrub (jump to target, no tween). Adds an altitude-lock e2e asserting a partial drag settles on an integer altitude after release. Full altitude-lock (14) + a11y + i18n suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 30 ++++++++++++++++++++++- simulator/static/app.js | 6 ++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index b86c830..f6aeb3a 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -276,7 +276,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page } const box = (await page.locator("#dial").boundingBox())!; const cx = box.x + box.width / 2, cy = box.y + box.height / 2; // Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents), - // crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete). + // crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING + // (the live blend holds wherever the knob is held; the snap happens only on release). await page.mouse.move(cx, cy - 30); await page.mouse.down(); await page.mouse.move(cx + 30, cy + 6, { steps: 14 }); @@ -293,3 +294,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page } expect(st2.activeClipId).toBe(st.activeClipId); await page.mouse.up(); }); + +test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => { + // The operator can grab the dial, turn it PART of a detent, and let go between two + // altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle + // there (integer position, frac 0) — landing on a discrete altitude exactly as if + // they had clicked it, rather than holding the half-finished blend. + await boot(page); + const box = (await page.locator("#dial").boundingBox())!; + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + await page.mouse.move(cx, cy - 30); + await page.mouse.down(); + await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes + // While held we are mid-blend: a fractional knob position. + await page.waitForFunction( + () => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; }, + null, { timeout: 5000 }, + ); + await page.mouse.up(); + // After release the position settles on an integer altitude (the snap). + await page.waitForFunction( + () => Number.isInteger((window as any).__hefState().pos), + null, { timeout: 10000 }, + ); + const st = await page.evaluate(() => (window as any).__hefState()); + expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend + expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 3023f57..3fbb19a 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1076,7 +1076,11 @@ function onDialUp(e) { } return; } - // No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model). + // Snap on release: a drag is a way of GRABBING the dial, not a free-standing + // encoder detent — so when the operator lets go between altitudes, auto-scrub the + // short way to the CLOSEST one and settle there (frac 0 locks). The result is the + // same as a tap/label-click: a grab-and-release always lands on a discrete altitude. + autoScrub(Math.round(pos)); } // Click a label → auto-scrub the SHORTEST signed way around the ring to that scale. -- 2.39.5