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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
});
|
||||
+29
-2
@@ -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).
|
||||
|
||||
@@ -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: "感じる" },
|
||||
|
||||
@@ -73,6 +73,11 @@
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<label class="dev-switch" for="auto-advance">
|
||||
<input type="checkbox" id="auto-advance" checked />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="altitude.autoadvance">Auto-advance</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
Reference in New Issue
Block a user