diff --git a/src/Engine/WorldModel.cs b/src/Engine/WorldModel.cs
index 2a6b91dfc..5ea02d048 100644
--- a/src/Engine/WorldModel.cs
+++ b/src/Engine/WorldModel.cs
@@ -1186,7 +1186,17 @@ internal void SignalTurnSuspended(bool scroll = true)
{
if (scroll && Version >= WorldModelVersion.v540)
{
+ // ScrollToEnd() must run first: it targets wherever MarkScrollPosition()
+ // was last called (the *start* of this turn's own output, set either by
+ // this method after the previous turn or by player.js/beginWait() for an
+ // interactive command/mid-turn wait). Only once that's queued do we
+ // record where this turn's output ends, ready as the target for
+ // whichever turn comes next — interactive command, walkthrough step,
+ // timer, or wait/ask/menu resumption, since they all funnel through this
+ // one hook (unlike the player.js/beginWait() JS-side calls, which only
+ // cover interactive typing and mid-turn waits).
ScrollToEnd();
+ MarkScrollPosition();
}
// Every wait()/get input()/ask/show menu call site pairs BeginPendingCallback/
// EndPendingCallbackAsync 1:1 with a SignalTurnSuspended call, and the two
@@ -1585,6 +1595,11 @@ private void ScrollToEnd()
_ = PlayerUi.RunScriptAsync("scrollToEnd", null);
}
+ private void MarkScrollPosition()
+ {
+ _ = PlayerUi.RunScriptAsync("markScrollPosition", null);
+ }
+
internal void LogException(Exception ex)
{
LogError?.Invoke(ex);
diff --git a/src/PlayerCore/Resources/playercore.css b/src/PlayerCore/Resources/playercore.css
index 02a666a93..461e36eb6 100644
--- a/src/PlayerCore/Resources/playercore.css
+++ b/src/PlayerCore/Resources/playercore.css
@@ -2,6 +2,19 @@
font-family: Arial, Helvetica, sans-serif;
overflow-y: scroll;
background-attachment: fixed;
+ /* playercore.js explicitly recomputes and sets scroll position itself on
+ every relevant DOM change (new turn, picture frame resizing — see
+ scrollToTurnStart()/setPanelHeight() in playercore.js), including
+ content inserted above the current scroll position (the sticky
+ picture frame growing once its image loads). Chrome's default scroll
+ anchoring tries to do the same job automatically to avoid a visual
+ jump, but its guess and playercore.js's own (more informed) target
+ can disagree — and since anchoring applies *before* playercore.js's
+ own correction runs, its adjustment becomes the new "current scroll
+ position" that scrollToTurnStart()'s never-scroll-backward guard then
+ treats as intentional, silently defeating the real correction.
+ Disabling it leaves scroll positioning entirely to our own logic. */
+ overflow-anchor: none;
}
html,
diff --git a/src/PlayerCore/Resources/playercore.js b/src/PlayerCore/Resources/playercore.js
index 093c6fbe4..41a4a094e 100644
--- a/src/PlayerCore/Resources/playercore.js
+++ b/src/PlayerCore/Resources/playercore.js
@@ -283,10 +283,22 @@ function initPlayerUI() {
}
};
+ // Fires from the picture frame's (SetFramePicture/
+ // JS.setPanelContents) once the image has actually finished loading and
+ // #gamePanel has its real, final height — before that, stickyOverlayHeight()
+ // only sees whatever height an unloaded renders at (effectively 0),
+ // so the turn's own scrollToEnd() call (fired the moment its text and the
+ // tag are added, well before the image itself has loaded) can
+ // under-count the frame and land the turn's opening line where the
+ // picture is about to appear. Re-targets scrollToTurnStart() at
+ // lastScrollTurnStart — a snapshot of whichever turn start that original
+ // call used — rather than re-reading beginningOfCurrentTurnScrollPosition,
+ // which markScrollPosition() has typically already advanced to the next
+ // turn's start by the time a real image finishes loading.
window.setPanelHeight = function () {
if (_showGrid) return;
setTimeout(function () {
- scrollToEnd();
+ scrollToTurnStart(lastScrollTurnStart);
}, 100);
};
@@ -495,13 +507,132 @@ function isElementVisible(element) {
var _animateScroll = true;
-function scrollToEnd() {
- if (!_animateScroll) {
- $('html,body').scrollTop(document.body.scrollHeight);
- } else {
- $('html,body').animate({scrollTop: document.body.scrollHeight}, 'fast');
+// The page's own scrollable content sits behind #qv-status (fixed) and
+// whichever of #gamePanel/#gridPanel (sticky — the static picture frame
+// feature, e.g. "The Shack") is showing a picture. Once scrolled past their
+// natural flow position they float on top of whatever's beneath them, so a
+// scroll target that ignores them can land content behind the frame instead
+// of below it. #gamePanel/#gridPanel's own "top" CSS already accounts for
+// #qv-status's height (see updateStatusVisibility()), so taking the deepest
+// bottom edge of the three — rather than summing them — avoids double
+// counting #qv-status when a picture is also showing.
+function stickyOverlayHeight() {
+ var bottom = 0;
+ ["#qv-status", "#gamePanel", "#gridPanel"].forEach(function (selector) {
+ var $el = $(selector);
+ if ($el.length && isElementVisible($el)) {
+ bottom = Math.max(bottom, (parseFloat($el.css("top")) || 0) + $el.outerHeight());
+ }
+ });
+ return bottom;
+}
+
+// Snapshot of whichever turn-start position the most recent scrollToEnd()
+// call actually used, kept separately from beginningOfCurrentTurnScrollPosition
+// — see setPanelHeight()'s doc comment (above, in initPlayerUI()) for why.
+var lastScrollTurnStart = 0;
+
+// Scrolls just far enough that turnStart (the top of some turn's output)
+// lands below the fixed/sticky chrome at the top of the viewport, rather than
+// jumping straight to the bottom of the document — otherwise a long turn's
+// opening lines scroll off the top (or behind the status bar/picture frame)
+// before the player can read them. Never scrolls backward past wherever the
+// player currently is (e.g. if they've scrolled up to reread), and never
+// past the true bottom of the document.
+//
+// A turn can emit several OutputText calls, each triggering scrollToEnd(),
+// and — for a walkthrough or any other rapid burst of turns — the next one
+// can easily land while the previous call's animation is still running.
+// easeInOutCubic decelerates to a near-stop at the end of every animation;
+// restarting that curve from scratch on each interruption made the scroll
+// visibly stutter (slow down, barely move, speed up, slow down again) rather
+// than glide, with the final leg then covering whatever distance was left in
+// one comparatively large jump once nothing interrupted it further. Only the
+// very first call in a burst (starting from a stationary page) gets the
+// eased curve; anything that interrupts an already-running scroll continues
+// at the same steady rate instead of re-decelerating and re-accelerating.
+//
+// allowBackward (default false) lets the target move the scroll position
+// *up*, not just down — normally refused so that a manual scroll-up to
+// reread history doesn't get yanked back down by an unrelated later turn.
+// clearScreen() passes true: a turn that clears the screen can still hit an
+// ordinary turn-boundary scrollToEnd() call using the *pre-clear* turn start
+// (SignalTurnSuspended's own call, queued before the script that called
+// ClearScreen has necessarily finished, can still be based on wherever the
+// old, now-discarded page happened to end) — clamped against the old,
+// now-irrelevant document height, that can scroll to a wrong, much-too-far
+// position before clearScreen's own correction below runs. Since the entire
+// point of a clear is "nothing before this matters any more", its own
+// correction must be allowed to override that, forward or backward.
+function scrollToTurnStart(turnStart, allowBackward) {
+ lastScrollTurnStart = turnStart;
+
+ // A picture frame image that hasn't finished loading yet (successfully
+ // or not) doesn't have its real size yet — #gamePanel/#gridPanel's
+ // height right now is whatever an unloaded renders at
+ // (effectively 0), so any target computed against it would undercount
+ // the frame and visibly place content where the picture is about to
+ // appear, needing a jump to fix once it loads. Skip for now instead:
+ // setPanelContents() wires up a load/error listener on every panel
+ // image (on top of SetFramePicture's own ) that calls back
+ // into here once the frame's real height is known, so this is never
+ // left stuck — just deferred until the answer is actually right.
+ var pendingImg = $("#gamePanel:visible img, #gridPanel:visible img").filter(function () {
+ return !this.complete;
+ })[0];
+ if (pendingImg) {
+ return;
}
- $("#txtCommand").focus();
+
+ if (!_animateScroll) {
+ // Used while fast-forwarding a walkthrough — always jump straight to
+ // the true end rather than following the turn-by-turn target below.
+ $('html,body').stop(true, false).scrollTop(document.body.scrollHeight);
+ focusCommandInput();
+ return;
+ }
+
+ var headerHeight = stickyOverlayHeight();
+ // turnStart is measured relative to #gameContent's own top (its height()
+ // when markScrollPosition() ran), not the page's — #gameContent isn't
+ // necessarily at page-top itself, since #gamePanel/#gridPanel (sticky,
+ // but still flow-participating) reserve real layout space above it
+ // whenever a picture is showing. Skipping this offset used to cancel out
+ // by accident whenever the frame was already showing before the turn
+ // started (its contribution to both this offset and headerHeight above
+ // were equal), which is why it went unnoticed until the frame's size
+ // changed *after* the turn had already been positioned.
+ var pageTurnStart = $("#gameContent").offset().top + turnStart;
+ var maxScrollTop = Math.max($(document).height() - $(window).height(), 0);
+ var target = Math.min(Math.max(pageTurnStart - headerHeight - 20, 0), maxScrollTop);
+ var currentScrollTop = Math.max($("body").scrollTop(), $("html").scrollTop());
+
+ if (target !== currentScrollTop && (allowBackward || target > currentScrollTop)) {
+ var wasAlreadyScrolling = $('html,body').is(":animated");
+ var distance = Math.abs(target - currentScrollTop);
+ var duration = Math.min(distance / 0.4, 2000);
+ var easing = wasAlreadyScrolling ? "linear" : "easeInOutCubic";
+ $('html,body').stop(true, false).animate({scrollTop: target}, duration, easing);
+ }
+ focusCommandInput();
+}
+
+// Plain jQuery .focus() calls the native HTMLElement.focus(), which — for an
+// element not already focused — asks the browser to scroll it into view.
+// #txtCommand sits right after all of #divOutput, so whenever it isn't
+// already focused (page just loaded, on mobile before the on-screen keyboard
+// has been tapped, focus lost to a click/selection elsewhere) that silently
+// jumped straight to the document's bottom, undoing every calculation above.
+// {preventScroll: true} keeps the input usable (typing still works
+// immediately) without fighting the scroll position we just computed.
+function focusCommandInput() {
+ document.getElementById("txtCommand")?.focus({preventScroll: true});
+}
+
+// beginningOfCurrentTurnScrollPosition is set by markScrollPosition() before
+// the turn began (see its own doc comment).
+function scrollToEnd() {
+ scrollToTurnStart(beginningOfCurrentTurnScrollPosition);
}
function SetAnimateScroll(value) {
@@ -538,6 +669,12 @@ function setPanelContents(html) {
$("#gamePanel").hide()
}
$("#gamePanel").html(html);
+ // Belt-and-braces alongside SetFramePicture's own
+ // markup: guarantees a retry once any image here finishes loading (success
+ // or failure) even for panel HTML set directly via JS.setPanelContents that
+ // doesn't happen to include that onload itself — scrollToTurnStart() below
+ // relies on one of these eventually firing to know it's safe to scroll.
+ $("#gamePanel img").one("load error", setPanelHeight);
setPanelHeight();
}
@@ -920,8 +1057,18 @@ function clearScreen() {
$("#divOutput").html("");
createNewDiv("left");
beginningOfCurrentTurnScrollPosition = 0;
+ // scrollToTurnStart(0), not a bare scrollTop(0) — the new page's
+ // content starts at #gameContent height 0, but #gameContent itself
+ // isn't necessarily at the very top of the *page*: a static picture
+ // frame (#gamePanel/#gridPanel, e.g. "The Shack") reserves real flow
+ // space above it, and jumping to page-scrollTop 0 only clears that
+ // frame if the frame is shorter than the viewport — on a small/mobile
+ // screen it very often isn't, leaving the new page's opening line
+ // rendered behind the frame. This fires on every gamebook page
+ // transition (DoPage → ClearScreen when game.clearlastpage), which
+ // includes game start (the player's first changedparent).
setTimeout(function () {
- $("html,body").scrollTop(0);
+ scrollToTurnStart(0, true);
}, 100);
} else {
$("#divOutput").append("
");
@@ -935,7 +1082,7 @@ function clearScreen() {
createNewDiv('left');
beginningOfCurrentTurnScrollPosition = 0;
setTimeout(function () {
- $('html,body').scrollTop(0);
+ scrollToTurnStart(0, true);
}, 100);
}
$("#outputData").appendTo($("#divOutput"));
diff --git a/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx b/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx
new file mode 100644
index 000000000..cb72fede3
--- /dev/null
+++ b/tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+ gopage1
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx b/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx
new file mode 100644
index 000000000..f50f35e65
--- /dev/null
+++ b/tests/e2e/fixtures/scroll-clearscreen-frame-test.aslx
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+ showframe
+
+
+
+ gotopage2
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/scroll-frame-test.aslx b/tests/e2e/fixtures/scroll-frame-test.aslx
new file mode 100644
index 000000000..bcc0c8c7b
--- /dev/null
+++ b/tests/e2e/fixtures/scroll-frame-test.aslx
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+ showframe
+
+
+
+ shout1
+
+
+
+ shout2
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/scroll-walkthrough-test.aslx b/tests/e2e/fixtures/scroll-walkthrough-test.aslx
new file mode 100644
index 000000000..06f4a51b4
--- /dev/null
+++ b/tests/e2e/fixtures/scroll-walkthrough-test.aslx
@@ -0,0 +1,418 @@
+
+
+
+
+
+
+
+
+
+
+ shout1
+
+
+
+ shout2
+
+
+
+ shout3
+
+
+
+ shout4
+
+
+
+ shout5
+
+
+
+ shout6
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs
new file mode 100644
index 000000000..4ce602e5b
--- /dev/null
+++ b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-after-wait.mjs
@@ -0,0 +1,109 @@
+// Manual verification for a stale-turnStart bug found by testing against
+// the real "The Shack" (id ihxy6CUAKkarClWOh2pWag): a script that prints a
+// long page, wait()s, then resumes and calls ClearScreen followed by a much
+// shorter page used to leave the short page's opening line scrolled off the
+// top — landing at the *old* (pre-clear) page's scroll target instead.
+//
+// Sequence: SignalTurnSuspended's own ordinary end-of-turn scrollToEnd()
+// call still fires when the turn suspends for wait() the *second* time
+// (after the resumed script's ClearScreen), but ClearScreen ran mid-script,
+// so beginningOfCurrentTurnScrollPosition read by that point is whatever a
+// concurrent flush left it at — in practice this reproduced as scrolling to
+// the (large, now-irrelevant) old page's clamped-to-document-bottom
+// position, which the "never scroll backward" guard then refused to correct
+// once clearScreen()'s own delayed 100ms scrollToTurnStart(0) call (with the
+// *right* answer) ran afterwards, since it was asking to scroll backward
+// from where the stale call had already landed.
+//
+// Fixed by letting clearScreen()'s own correction move the scroll position
+// backward (scrollToTurnStart(0, true)) — a screen clear makes "wherever the
+// user happened to be scrolled" irrelevant, so it should always win.
+//
+// Fixture: tests/e2e/fixtures/scroll-clearscreen-after-wait-test.aslx's
+// "gopage1" command prints ~40 lines (#page1mark at the start), wait()s,
+// then on resume calls ClearScreen and prints two short lines (#page2mark).
+// The debugger's walkthrough runner auto-resolves wait() calls, matching how
+// The Shack's WalkthroughRunner-driven "Continue" links behave.
+//
+// Requires the WasmPlayer dev server running locally:
+// node ../../src/WasmPlayer/dev-server.mjs
+import { chromium } from 'playwright';
+
+const baseUrl = process.argv[2] || 'http://localhost:5175';
+
+const browser = await chromium.launch();
+let failed = false;
+
+function check(label, condition) {
+ console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`);
+ if (!condition) failed = true;
+}
+
+const context = await browser.newContext({ viewport: { width: 375, height: 500 } });
+const previewPage = await context.newPage();
+previewPage.on('pageerror', err => console.log('[pageerror]', err.message));
+
+const senderPage = await context.newPage();
+const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-clearscreen-after-wait-test.aslx`;
+await senderPage.goto(baseUrl);
+
+const handoffDone = senderPage.evaluate(async (url) => {
+ const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
+ await new Promise((resolve) => {
+ const bc = new BroadcastChannel('quest-preview');
+ bc.onmessage = ({ data }) => {
+ if (data.type === 'ready') {
+ bc.postMessage({ type: 'game', bytes, filename: 'scroll-clearscreen-after-wait-test.aslx' });
+ resolve();
+ }
+ };
+ });
+}, fixtureUrl);
+
+await previewPage.goto(`${baseUrl}/?source=editor`);
+await handoffDone;
+
+await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 });
+await previewPage.waitForTimeout(300);
+
+await previewPage.click('#cmdDebug');
+await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 });
+await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")');
+await previewPage.click('#qv-debugger-list [data-item="clearafterwait"]');
+await previewPage.waitForSelector('[data-run-walkthrough="clearafterwait"]');
+await previewPage.click('[data-run-walkthrough="clearafterwait"]');
+
+await previewPage.waitForFunction(() => {
+ const el = document.querySelector('[data-walkthrough-status]');
+ return el && el.textContent && el.textContent !== 'Running…';
+}, { timeout: 15000 });
+const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent);
+check('Walkthrough runs to completion', status === 'Done');
+
+await previewPage.click('#qv-debugger-close');
+
+// clearScreen()'s own scroll is on a 100ms setTimeout; give the (possibly
+// up-to-2s) resulting animation time to settle too.
+await previewPage.waitForTimeout(2500);
+
+const page1Exists = await previewPage.$('#page1mark');
+check('Page 1 content was actually cleared (not just scrolled past)', page1Exists === null);
+
+const page2Rect = await previewPage.$eval('#page2mark', el => el.getBoundingClientRect());
+console.log(' #page2mark rect:', JSON.stringify(page2Rect));
+check(
+ 'Page 2 start is visible in the viewport, not scrolled off the top by a stale pre-clear target',
+ page2Rect.top >= 0 && page2Rect.top < 500
+);
+
+const scrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollTop);
+console.log(' final scrollTop:', scrollTop);
+check('Final scroll position is near the top of the (now very short) cleared page', scrollTop < 100);
+
+await browser.close();
+
+if (failed) {
+ console.error('\nFAIL: one or more checks failed.');
+ process.exit(1);
+}
+console.log('\nPASS');
diff --git a/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs
new file mode 100644
index 000000000..4d0e63eeb
--- /dev/null
+++ b/tests/e2e/verify-wasmplayer-autoscroll-clearscreen-frame.mjs
@@ -0,0 +1,101 @@
+// Manual verification that ClearScreen() (JS.clearScreen(), used by gamebook
+// mode's DoPage on every page transition — including game start, via
+// defaultplayer's changedparent — whenever game.clearlastpage is set, e.g.
+// "The Shack"'s Continue links) doesn't leave the new page's opening line
+// hidden behind a tall static picture frame (#gamePanel).
+//
+// clearScreen() used to hard-jump to page scrollTop 0 regardless of the
+// frame. That's only correct if the frame is shorter than the viewport; on
+// a small/mobile screen a full-width picture very often isn't, so the new
+// page's first line rendered behind it. Fixed by routing through
+// scrollToTurnStart(0) — the same "reveal what's below the sticky chrome"
+// logic normal turns already use — instead of a bare scrollTop(0).
+//
+// Requires the WasmPlayer dev server running locally:
+// node ../../src/WasmPlayer/dev-server.mjs
+import { chromium } from 'playwright';
+
+const baseUrl = process.argv[2] || 'http://localhost:5175';
+
+const browser = await chromium.launch();
+let failed = false;
+
+function check(label, condition) {
+ console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`);
+ if (!condition) failed = true;
+}
+
+// Small/mobile-sized viewport, matching the user's report that this is
+// specifically where it's noticeable. The fixture's frame is 200px tall —
+// well under the real feature's own cap (div#gamePanel img's max-height is
+// dynamically set to 50% of window height, see updatePanelImageMaxHeight()
+// in playercore.js), so it fits under this 500px viewport with room to
+// spare, same as a real picture would.
+const context = await browser.newContext({ viewport: { width: 375, height: 500 } });
+const previewPage = await context.newPage();
+previewPage.on('pageerror', err => console.log('[pageerror]', err.message));
+
+const senderPage = await context.newPage();
+const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-clearscreen-frame-test.aslx`;
+await senderPage.goto(baseUrl);
+
+const handoffDone = senderPage.evaluate(async (url) => {
+ const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
+ await new Promise((resolve) => {
+ const bc = new BroadcastChannel('quest-preview');
+ bc.onmessage = ({ data }) => {
+ if (data.type === 'ready') {
+ bc.postMessage({ type: 'game', bytes, filename: 'scroll-clearscreen-frame-test.aslx' });
+ resolve();
+ }
+ };
+ });
+}, fixtureUrl);
+
+await previewPage.goto(`${baseUrl}/?source=editor`);
+await handoffDone;
+
+await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 });
+await previewPage.waitForTimeout(300);
+
+await previewPage.click('#cmdDebug');
+await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 });
+await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")');
+await previewPage.click('#qv-debugger-list [data-item="clearwithframe"]');
+await previewPage.waitForSelector('[data-run-walkthrough="clearwithframe"]');
+await previewPage.click('[data-run-walkthrough="clearwithframe"]');
+
+await previewPage.waitForFunction(() => {
+ const el = document.querySelector('[data-walkthrough-status]');
+ return el && el.textContent && el.textContent !== 'Running…';
+}, { timeout: 15000 });
+const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent);
+check('Walkthrough runs to completion', status === 'Done');
+
+await previewPage.click('#qv-debugger-close');
+
+// clearScreen()'s own scroll is on a 100ms setTimeout; give the (possibly
+// up-to-2s) resulting animation time to settle too.
+await previewPage.waitForTimeout(2500);
+
+const frameRect = await previewPage.$eval('#gamePanel', el => el.getBoundingClientRect());
+const pageMarkRect = await previewPage.$eval('#page2mark', el => el.getBoundingClientRect());
+console.log(' #gamePanel rect:', JSON.stringify(frameRect));
+console.log(' #page2mark rect:', JSON.stringify(pageMarkRect));
+
+check(
+ 'Page 2 start is visible in the viewport at all',
+ pageMarkRect.top >= 0 && pageMarkRect.top < 500
+);
+check(
+ 'Page 2 start is not hidden behind the sticky picture frame after ClearScreen',
+ pageMarkRect.top >= frameRect.bottom
+);
+
+await browser.close();
+
+if (failed) {
+ console.error('\nFAIL: one or more checks failed.');
+ process.exit(1);
+}
+console.log('\nPASS');
diff --git a/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs b/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs
new file mode 100644
index 000000000..dd7a311b8
--- /dev/null
+++ b/tests/e2e/verify-wasmplayer-autoscroll-frame-load-race.mjs
@@ -0,0 +1,94 @@
+// Manual verification that growing the static picture frame (#gamePanel —
+// SetFramePicture/JS.setPanelContents → setPanelHeight(), e.g. "The Shack")
+// after a turn has already ended doesn't leave that turn's opening line
+// hidden behind the frame.
+//
+// Root cause (found via this test): scrollToTurnStart() unconditionally
+// called $("#txtCommand").focus() after positioning the scroll. Calling
+// .focus() on an element that ISN'T already the focused one makes the
+// browser scroll it into view natively — and since #txtCommand sits right
+// after all of #divOutput, that silently jumped straight to the document's
+// bottom, discarding every position calculated above it. #txtCommand is
+// often not already focused in exactly this scenario: right after page
+// load, on mobile before the on-screen keyboard has been tapped, or any
+// time focus was lost to a click/selection elsewhere — which is why this
+// was "more noticeable on a small [mobile] browser window". Fixed by
+// focusing with {preventScroll: true} (focusCommandInput()).
+//
+// This test simulates a slow-loading picture directly (real network image
+// timing isn't controllable/deterministic here): run a normal turn with the
+// frame still at its natural (empty, ~0-height) size, let the turn boundary
+// finish and settle as it normally would — including losing input focus,
+// simulating a player who tapped/clicked elsewhere — then simulate the
+// image's delayed onload firing afterwards by growing #gamePanel and calling
+// window.setPanelHeight() directly, exactly what the real onload handler does.
+//
+// Requires the WasmPlayer dev server running locally:
+// node ../../src/WasmPlayer/dev-server.mjs
+import { chromium } from 'playwright';
+
+const baseUrl = process.argv[2] || 'http://localhost:5175';
+
+const browser = await chromium.launch();
+let failed = false;
+
+function check(label, condition) {
+ console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`);
+ if (!condition) failed = true;
+}
+
+const page = await browser.newPage({ viewport: { width: 500, height: 500 } });
+page.on('pageerror', err => console.log('[pageerror]', err.message));
+
+await page.goto(`${baseUrl}/?url=/e2e-fixtures/scroll-walkthrough-test.aslx`);
+await page.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 });
+await page.waitForTimeout(300);
+
+console.log('typing shout1 (frame still empty/0-height, as it would be before a real image loads)');
+await page.fill('#txtCommand', 'shout1');
+await page.press('#txtCommand', 'Enter');
+await page.waitForFunction(() => document.querySelector('#divOutput')?.textContent.includes('Turn 1, filler line 60'), { timeout: 15000 });
+// Let the turn-boundary scroll settle, exactly as it would in real play.
+await page.waitForTimeout(1000);
+
+const turnmark1BeforeGrow = await page.$eval('#turnmark1', el => el.getBoundingClientRect());
+console.log(' #turnmark1 rect before frame grows:', JSON.stringify(turnmark1BeforeGrow));
+
+// Blur the input first — simulating a player who tapped/clicked elsewhere
+// (or a mobile session where it was never focused in the first place), the
+// exact condition that exposed the .focus()-triggered scroll hijack.
+await page.evaluate(() => document.getElementById('txtCommand')?.blur());
+
+console.log('simulating the picture finishing loading well after the turn ended (growing #gamePanel + calling window.setPanelHeight())');
+await page.evaluate(() => {
+ const panel = document.getElementById('gamePanel');
+ panel.innerHTML = 'FRAME';
+ panel.style.cssText += ';display:block;height:150px;background:red;';
+ window.setPanelHeight();
+});
+
+// setPanelHeight() itself waits 100ms before re-scrolling; give the
+// (possibly up-to-2s) resulting animation time to settle too.
+await page.waitForTimeout(2500);
+
+const frameRect = await page.$eval('#gamePanel', el => el.getBoundingClientRect());
+const turnmark1Rect = await page.$eval('#turnmark1', el => el.getBoundingClientRect());
+console.log(' #gamePanel rect after growing:', JSON.stringify(frameRect));
+console.log(' #turnmark1 rect after re-scroll:', JSON.stringify(turnmark1Rect));
+
+check(
+ 'Turn 1 start is still on screen after the frame grows',
+ turnmark1Rect.top >= 0 && turnmark1Rect.top < 500
+);
+check(
+ 'Turn 1 start is not left hidden behind the now-fully-sized picture frame',
+ turnmark1Rect.top >= frameRect.bottom
+);
+
+await browser.close();
+
+if (failed) {
+ console.error('\nFAIL: one or more checks failed.');
+ process.exit(1);
+}
+console.log('\nPASS');
diff --git a/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs b/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs
new file mode 100644
index 000000000..6cb23135a
--- /dev/null
+++ b/tests/e2e/verify-wasmplayer-autoscroll-frame.mjs
@@ -0,0 +1,98 @@
+// Manual verification that scrollToEnd()'s "don't scroll the start of new
+// output off the top" target accounts for the static picture frame feature
+// (#gamePanel — position:sticky, shown via SetFramePicture/JS.setPanelContents,
+// e.g. used by "The Shack") in addition to the #qv-status toolbar. Before
+// this fix, stickyOverlayHeight() only looked at #qv-status, so a visible
+// picture frame would sit on top of (hide) the very turn-start line the
+// scroll was supposed to reveal.
+//
+// Fixture: tests/e2e/fixtures/scroll-frame-test.aslx defines "showframe"
+// (sets a 150px-tall #gamePanel via JS.setPanelContents) and shout1/shout2
+// (long filler output with a at the very start),
+// wired into a "withframe" walkthrough that shows the frame then runs both
+// shouts.
+//
+// Requires the WasmPlayer dev server running locally:
+// node ../../src/WasmPlayer/dev-server.mjs
+import { chromium } from 'playwright';
+
+const baseUrl = process.argv[2] || 'http://localhost:5175';
+
+const browser = await chromium.launch();
+let failed = false;
+
+function check(label, condition) {
+ console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`);
+ if (!condition) failed = true;
+}
+
+const context = await browser.newContext({ viewport: { width: 1024, height: 768 } });
+const previewPage = await context.newPage();
+previewPage.on('pageerror', err => console.log('[pageerror]', err.message));
+
+const senderPage = await context.newPage();
+const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-frame-test.aslx`;
+await senderPage.goto(baseUrl);
+
+const handoffDone = senderPage.evaluate(async (url) => {
+ const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
+ await new Promise((resolve) => {
+ const bc = new BroadcastChannel('quest-preview');
+ bc.onmessage = ({ data }) => {
+ if (data.type === 'ready') {
+ bc.postMessage({ type: 'game', bytes, filename: 'scroll-frame-test.aslx' });
+ resolve();
+ }
+ };
+ });
+}, fixtureUrl);
+
+await previewPage.goto(`${baseUrl}/?source=editor`);
+await handoffDone;
+
+await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 });
+await previewPage.waitForTimeout(300);
+
+await previewPage.click('#cmdDebug');
+await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 });
+await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")');
+await previewPage.click('#qv-debugger-list [data-item="withframe"]');
+await previewPage.waitForSelector('[data-run-walkthrough="withframe"]');
+await previewPage.click('[data-run-walkthrough="withframe"]');
+
+await previewPage.waitForFunction(() => {
+ const el = document.querySelector('[data-walkthrough-status]');
+ return el && el.textContent && el.textContent !== 'Running…';
+}, { timeout: 15000 });
+const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent);
+check('Walkthrough runs to completion', status === 'Done');
+
+await previewPage.click('#qv-debugger-close');
+
+// Let scroll animations settle.
+await previewPage.waitForTimeout(2500);
+
+const frameVisible = await previewPage.$eval('#gamePanel', el => getComputedStyle(el).display !== 'none');
+check('#gamePanel (picture frame) is showing', frameVisible);
+
+const frameRect = await previewPage.$eval('#gamePanel', el => el.getBoundingClientRect());
+const turn2Rect = await previewPage.$eval('#turnmark2', el => el.getBoundingClientRect());
+console.log(' #gamePanel rect:', JSON.stringify(frameRect));
+console.log(' #turnmark2 rect:', JSON.stringify(turn2Rect));
+
+check(
+ 'Final turn start (#turnmark2) is visible in the viewport at all',
+ turn2Rect.top >= 0 && turn2Rect.top < 768
+);
+check(
+ 'Final turn start is not hidden behind the sticky picture frame',
+ turn2Rect.top >= frameRect.bottom
+);
+
+await browser.close();
+
+if (failed) {
+ console.error('\nFAIL: one or more checks failed.');
+ process.exit(1);
+}
+console.log('\nPASS');
diff --git a/tests/e2e/verify-wasmplayer-autoscroll.mjs b/tests/e2e/verify-wasmplayer-autoscroll.mjs
new file mode 100644
index 000000000..39bdb85da
--- /dev/null
+++ b/tests/e2e/verify-wasmplayer-autoscroll.mjs
@@ -0,0 +1,137 @@
+// Manual verification for auto-scroll behaviour in the game output panel
+// (playercore.js's scrollToEnd()/markScrollPosition()). Bug report: on a
+// turn producing a long stream of output, the page should scroll just far
+// enough that the *start* of that turn's output stays on screen, not jump
+// straight to the document's bottom (which scrolls the beginning of the new
+// text off the top). scrollToEnd() used to always target
+// document.body.scrollHeight unconditionally, and queued a fresh jQuery
+// .animate() on every OutputText call without .stop()'ing the previous one
+// first — on a walkthrough firing several such turns back-to-back with no
+// delay, that produced exactly the reported "scrolls immediately, then
+// stalls, then slowly catches up" symptom (a growing backlog of queued
+// animations targeting stale, already-passed scroll positions).
+//
+// Fixture: tests/e2e/fixtures/scroll-walkthrough-test.aslx defines six
+// commands (shout1..shout6), each printing ~30 lines of filler text (well
+// over one screenful) preceded by a marker at the very
+// start of that turn's output. The "rapid" walkthrough fires all six with no
+// delay between them — the exact repro shape (WasmPlayer's debugger
+// walkthrough runner never disables animation, unlike WebPlayer's).
+//
+// Requires the WasmPlayer dev server running locally:
+// node ../../src/WasmPlayer/dev-server.mjs
+import { chromium } from 'playwright';
+
+const baseUrl = process.argv[2] || 'http://localhost:5175';
+
+const browser = await chromium.launch();
+let failed = false;
+
+function check(label, condition) {
+ console.log(`${condition ? 'PASS' : 'FAIL'}: ${label}`);
+ if (!condition) failed = true;
+}
+
+const context = await browser.newContext({ viewport: { width: 1024, height: 768 } });
+const previewPage = await context.newPage();
+previewPage.on('pageerror', err => console.log('[pageerror]', err.message));
+previewPage.on('console', msg => console.log(`[console.${msg.type()}]`, msg.text()));
+
+const senderPage = await context.newPage();
+const fixtureUrl = `${baseUrl}/e2e-fixtures/scroll-walkthrough-test.aslx`;
+await senderPage.goto(baseUrl);
+
+const handoffDone = senderPage.evaluate(async (url) => {
+ const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
+ await new Promise((resolve) => {
+ const bc = new BroadcastChannel('quest-preview');
+ bc.onmessage = ({ data }) => {
+ if (data.type === 'ready') {
+ bc.postMessage({ type: 'game', bytes, filename: 'scroll-walkthrough-test.aslx' });
+ resolve();
+ }
+ };
+ });
+}, fixtureUrl);
+
+await previewPage.goto(`${baseUrl}/?source=editor`);
+await handoffDone;
+
+await previewPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 30000 });
+await previewPage.waitForTimeout(300);
+
+// Sample document.scrollingElement.scrollTop throughout the run so we can
+// see the shape of the scroll over time, not just the end state.
+await previewPage.evaluate(() => {
+ window.__scrollSamples = [];
+ window.__scrollSampleTimer = setInterval(() => {
+ window.__scrollSamples.push(document.scrollingElement.scrollTop);
+ }, 50);
+});
+
+console.log('step: click debug');
+await previewPage.click('#cmdDebug', { timeout: 10000 });
+console.log('step: wait dialog open');
+await previewPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 });
+console.log('step: click walkthrough tab');
+await previewPage.click('#qv-debugger-tabs button:text("Walkthrough")', { timeout: 10000 });
+console.log('step: select rapid');
+await previewPage.click('#qv-debugger-list [data-item="rapid"]', { timeout: 10000 });
+console.log('step: wait run button');
+await previewPage.waitForSelector('[data-run-walkthrough="rapid"]', { timeout: 10000 });
+console.log('step: click run');
+await previewPage.click('[data-run-walkthrough="rapid"]', { timeout: 10000 });
+
+console.log('step: wait for Done');
+await previewPage.waitForFunction(() => {
+ const el = document.querySelector('[data-walkthrough-status]');
+ return el && el.textContent && el.textContent !== 'Running…';
+}, { timeout: 15000 });
+console.log('step: got status');
+const status = await previewPage.$eval('[data-walkthrough-status]', el => el.textContent);
+check('Walkthrough runs to completion', status === 'Done');
+
+await previewPage.click('#qv-debugger-close');
+
+// Let any trailing scroll animation settle (scrollToEnd()'s animation duration
+// is capped at 2000ms), then stop sampling.
+await previewPage.waitForTimeout(2500);
+const samples = await previewPage.evaluate(() => {
+ clearInterval(window.__scrollSampleTimer);
+ return window.__scrollSamples;
+});
+console.log(' scrollTop samples over time:', samples.join(', '));
+
+// ── The core assertion: the last turn's marker should still be near the top
+// of the viewport, not scrolled past (off the top) or left far below (i.e.
+// the fix should have scrolled *to* it, not *past* it to the document end).
+const turn6Rect = await previewPage.$eval('#turnmark6', el => el.getBoundingClientRect());
+console.log(' #turnmark6 rect:', JSON.stringify(turn6Rect));
+check(
+ 'Start of the final turn (#turnmark6) is visible, not scrolled off the top',
+ turn6Rect.top >= 0 && turn6Rect.top < 768
+);
+
+const finalScrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollTop);
+const maxScrollTop = await previewPage.evaluate(() => document.scrollingElement.scrollHeight - window.innerHeight);
+console.log(` finalScrollTop=${finalScrollTop} maxScrollTop=${maxScrollTop}`);
+check(
+ 'Final scroll position stops well short of the true document bottom (does not overshoot past the last turn\'s start)',
+ finalScrollTop < maxScrollTop - 200
+);
+
+// ── No pathological backlog: once scrolling reaches its final position it
+// should stay flat, not keep creeping upward/downward well after the
+// walkthrough itself already reported Done (a queued-animation backlog
+// draining after the fact).
+const tailSamples = samples.slice(-6);
+const tailSpread = Math.max(...tailSamples) - Math.min(...tailSamples);
+check('Scroll position is settled (not still drifting) by the time sampling stops', tailSpread < 5);
+
+await browser.close();
+
+if (failed) {
+ console.error('\nFAIL: one or more checks failed.');
+ process.exit(1);
+}
+console.log('\nPASS');