From 9d42ed7fc4c55d6ef47359e41cf8431cad9eef3d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 05:44:41 -0700 Subject: [PATCH] feat(altitude): auto-advance one altitude per minute when idle After the dial sits idle for 60s the altitude drifts one step deeper (descend; wraps past the deepest back to the top), like a slow kiosk tour. Any manual interaction (drag/wheel/arrow-key/knob) restarts the idle countdown; a mid-morph or mid-drag beat is skipped and retried. A default-on 'Auto-advance' toggle in the Altitude panel disables it. Interval is injectable for tests via window.__hefAutoAdvanceMs. i18n for 4 langs. +3 e2e (auto-advance.spec.ts). Co-Authored-By: Claude Opus 4.8 --- simulator/e2e/tests/auto-advance.spec.ts | 52 ++++++++++++++++++++++++ simulator/static/app.js | 31 +++++++++++++- simulator/static/i18n.js | 1 + simulator/static/index.html | 5 +++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 simulator/e2e/tests/auto-advance.spec.ts diff --git a/simulator/e2e/tests/auto-advance.spec.ts b/simulator/e2e/tests/auto-advance.spec.ts new file mode 100644 index 0000000..88d5205 --- /dev/null +++ b/simulator/e2e/tests/auto-advance.spec.ts @@ -0,0 +1,52 @@ +import { test, expect, Page } from "@playwright/test"; + +// Auto-advance: after the dial sits idle for the interval, the altitude drifts one +// step deeper (descend), wrapping past the deepest. A default-on toggle disables it. +// The e2e injects a short interval via window.__hefAutoAdvanceMs before load. + +async function boot(page: Page, intervalMs: number) { + await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs); + await page.goto("/"); + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} + +const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number); + +test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => { + await boot(page, 1200); + const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length)); + const start = await idx(page); + // Wait past the interval + the morph animation for the landing. + await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 }); + const after = await idx(page); + expect(after).toBe((start + 1) % n); // descended exactly one, wraps +}); + +test("toggling Auto-advance off keeps the altitude parked", async ({ page }) => { + await boot(page, 1000); + await page.evaluate(() => { + const c = document.querySelector("#auto-advance") as HTMLInputElement; + c.checked = false; + c.dispatchEvent(new Event("change", { bubbles: true })); + }); + const start = await idx(page); + await page.waitForTimeout(2600); // well past two intervals + expect(await idx(page)).toBe(start); // never moved +}); + +test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => { + await boot(page, 1500); + // Immediately before the first interval elapses, interact (keyboard step). That step + // itself advances by 1; the reset then means no AUTO advance fires for another + // full interval — so shortly after, we should still be at exactly that +1. + await page.waitForTimeout(900); + const before = await idx(page); + await page.locator("#dial").focus(); + await page.locator("#dial").press("ArrowDown"); // manual descend + reset + await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 }); + const manual = await idx(page); + await page.waitForTimeout(900); // < the 1500ms interval + expect(await idx(page)).toBe(manual); // countdown reset → no extra auto step yet +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 1a0ce84..4902f1c 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -848,6 +848,7 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) { let wheelAccum = 0, wheelTimer = null; function onWheel(e) { e.preventDefault(); + bumpAutoAdvance(); wheelAccum += e.deltaY; clearTimeout(wheelTimer); wheelTimer = setTimeout(() => { @@ -857,6 +858,27 @@ function onWheel(e) { }, 90); } +// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude +// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any +// manual interaction restarts the idle countdown; a default-on toggle disables it. +// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.) +const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000; +let autoAdvanceTimer = null; +function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; } +function scheduleAutoAdvance() { + clearTimeout(autoAdvanceTimer); + autoAdvanceTimer = null; + if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running + autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS); +} +function onAutoAdvance() { + // Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing. + if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1); + scheduleAutoAdvance(); +} +// Restart the idle countdown on any manual interaction (drag/wheel/key/knob). +function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); } + // --- Altitude knob: the endless rotary encoder, drawn as a turnable dial --- // cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales // (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the @@ -1064,6 +1086,7 @@ function setPos(next) { function onDialDown(e) { if (!ring || ring.scales.length < 2) return; e.preventDefault(); + bumpAutoAdvance(); dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos }; } function onDialMove(e) { @@ -1090,6 +1113,7 @@ function onDialUp(e) { // 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)); + bumpAutoAdvance(); } // Click a label → auto-scrub the SHORTEST signed way around the ring to that scale. @@ -1120,7 +1144,7 @@ function onDialKey(e) { case "End": jumpToScale(ring.scales.length - 1); break; default: handled = false; } - if (handled) e.preventDefault(); + if (handled) { e.preventDefault(); bumpAutoAdvance(); } } // --- Dev Mode: pool picker + live analysis, all under one toggle --- @@ -1582,6 +1606,7 @@ function beginExperience() { updateAudioLevelLabel(); applyAudio(); applyPlaySpeed(); + scheduleAutoAdvance(); // start the idle-drift countdown now that we're running } // Welcome screen ---------------------------------------------------------------- @@ -1646,7 +1671,9 @@ async function main() { initWelcome(); // wire the welcome-screen controls + Launch button renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". - for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced); + for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); }); + // Auto-advance toggle: (re)start or cancel the idle-drift countdown on change. + $("auto-advance").addEventListener("change", scheduleAutoAdvance); // Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this // input gesture so the first raise from 0 unlocks + plays on Safari (which blocks // play() outside a user gesture). diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index b9d62ad..c7e1ab6 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -29,6 +29,7 @@ "output.audio.music": { en: "Music", es: "Música", fr: "Musique", ja: "音楽" }, "output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "音量" }, "altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" }, + "altitude.autoadvance": { en: "Auto-advance", es: "Avance automático", fr: "Avance automatique", ja: "自動で移動" }, "knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expérience", ja: "体験つまみ" }, "knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" }, "knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index c835324..b0b6179 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -73,6 +73,11 @@ aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"> +