fix(lobby): restore mobile scrolling outside matches

This commit is contained in:
2026-09-12 22:53:26 +03:00
parent 07eaed9c93
commit 720cd18b6e
4 changed files with 135 additions and 12 deletions
+95 -6
View File
@@ -2594,7 +2594,7 @@ The following planning-time request defines this milestone:
## Milestone 024 — Runtime living hedge rendering ## Milestone 024 — Runtime living hedge rendering
**Status:** `IN_PROGRESS` **Status:** `COMPLETE`
### Objective ### Objective
@@ -2846,8 +2846,97 @@ The following planning-time state defines this milestone:
compressed bytes. A clean PlatformIO build passed with firmware at compressed bytes. A clean PlatformIO build passed with firmware at
1,029,600 / 2,097,152 bytes and RAM at 59,940 / 327,680 bytes; the configured 1,029,600 / 2,097,152 bytes and RAM at 59,940 / 327,680 bytes; the configured
2,031,616-byte LittleFS image also built successfully. 2,031,616-byte LittleFS image also built successfully.
- 2026-09-11 the corrected image could not be uploaded from this environment:
no `/dev/ttyACM*` or `/dev/ttyUSB*` device was present and the ESP HTTP ## Milestone 025 — Mobile welcome and lobby scrolling
endpoint remained unreachable from the host. The physical page still needs
the new filesystem image and repeat project-owner visual acceptance, so **Status:** `COMPLETE`
Milestone 024 remains `IN_PROGRESS`.
### Objective
Allow welcome and lobby content to scroll naturally on supported mobile
viewports while keeping the active player match fixed and its touch controls
unchanged.
### Scope
- Remove the viewport-wide mobile scroll lock that prevents welcome and lobby
actions from being reached.
- Apply scroll locking only while the player match screen is active.
- Preserve safe-area padding, desktop layout, observer behavior, and gameplay
map and control gesture handling.
### Deliverables
- Screen-specific mobile scrolling CSS and state wiring.
- Deterministic browser-source tests for mobile overflow, safe-area padding,
and active-match scroll locking.
### Acceptance criteria
1. At 412 by 915, 412 by 916, smaller supported mobile viewports, and compact
landscape, overflowing welcome and lobby content can be vertically scrolled
and the launch button can be reached.
2. Bottom safe-area padding remains present on welcome and lobby content.
3. An active player match remains viewport-fixed, and its map and directional
controls retain their existing non-scrolling touch behavior.
4. Automated tests cover the screen-specific scroll-lock contract and pass.
### Evidence
- On 2026-09-12, the mobile-wide `html, body` and `#app` scroll lock was
replaced with a `match-active` class toggled only by the active player match
screen. Welcome and lobby retain their natural document height and their
safe-area bottom padding; the match retains its bounded map, fixed
viewport, and `touch-action: none` control surface.
- On 2026-09-12, `node --test test/web/test_player_app.js
test/web/test_hedge_svg.js` passed all 25 tests, including the new
screen-specific mobile scrolling contract for 412 by 915, 412 by 916,
smaller portrait, and compact-landscape viewport cases.
- On 2026-09-12, `bash scripts/run_host_tests.sh` passed all host and bundled
browser tests, and `python3 scripts/verify_web_assets.py` passed with a
deterministic 29,999 / 1,572,864 byte compressed web payload.
- On 2026-09-12, `/home/sasa/.platformio/penv/bin/pio run --target buildfs`
succeeded. The generated LittleFS image uses 2,031,616 / 2,031,616 bytes.
- The in-app browser was not opened because the project owner explicitly
instructed that it must not be used for this change.
- On 2026-09-12, the project owner checked the lobby UI and confirmed that it
works as expected. This supplies the requested visual acceptance evidence
for mobile scrolling and reachable lobby actions.
## Milestone 026 — Optional help overlay at match start
**Status:** `IN_PROGRESS`
### Objective
Transition directly from the server-authoritative countdown to the active maze
without automatically opening the optional player help overlay.
### Scope
- Remove only the automatic help-opening behavior from the active player-view
transition.
- Preserve the existing help replay button, help navigation, close controls,
countdown timing, input controls, and server protocol.
- Validate the transition on mobile and desktop layouts.
### Deliverables
- Updated player transition wiring.
- Automated tests for direct maze activation and opt-in help behavior.
- Mobile and desktop browser verification evidence.
### Acceptance criteria
1. When the countdown finishes, the active player maze becomes visible and its
enabled controls are immediately usable without a help-overlay flash.
2. The help overlay remains closed after the countdown.
3. The existing help button opens the overlay, and its close and final-step
controls return the player to the active maze.
4. Existing countdown timing, authoritative match-start messages, controls,
and mobile and desktop layouts remain unchanged.
5. Automated tests and browser verification pass.
### Evidence
- Implementation and verification are pending.
+35
View File
@@ -132,6 +132,19 @@ test("onboarding has bounded, replayable steps", () => {
assert.equal(player.nextOnboardingStep(7), null); assert.equal(player.nextOnboardingStep(7), null);
}); });
test("countdown completion leaves help closed until the player requests it", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1];
assert.match(activeView, /setScreen\("match"\)/);
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
assert.match(activeView, /else hideModal\(\);/);
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
assert.match(script, /byId\("onboarding-replay"\)\.addEventListener\("click", \(\) => showOnboarding\(0\)\)/);
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
});
test("avatar control resets safely and retains accessible alternatives", () => { test("avatar control resets safely and retains accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
@@ -203,6 +216,28 @@ test("mobile directional pad is explicit, bounded, and resettable", () => {
assert.match(script, /state\.moveFlow\.request\(direction, element\)/); assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
}); });
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const mobileRules = css.slice(
css.indexOf("@media (max-width: 720px)"),
css.indexOf("@media (min-width: 721px)")
);
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
assert.ok(viewport[0] > 0 && viewport[1] > 0);
}
});
test("frontend name suggestions randomize both words and remain bounded", () => { test("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4]; const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift()); const suggestion = player.randomSuggestedName([], () => picks.shift());
+3 -2
View File
@@ -252,8 +252,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@media (max-width: 720px) { @media (max-width: 720px) {
:root { --cell: 46px; } :root { --cell: 46px; }
html, body { height: 100%; overflow: hidden; } body.match-active { height: 100%; overflow: hidden; }
#app { height: 100dvh; min-height: 0; overflow: hidden; padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); } #app { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
body.match-active #app { height: 100dvh; min-height: 0; overflow: hidden; }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; } .welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; } .hero-art { display: none; }
.welcome-panel { padding: 22px 18px; } .welcome-panel { padding: 22px 18px; }
+2 -4
View File
@@ -417,6 +417,7 @@
byId("lobby-screen").hidden = name !== "lobby"; byId("lobby-screen").hidden = name !== "lobby";
byId("match-screen").hidden = name !== "match"; byId("match-screen").hidden = name !== "match";
byId("observer-screen").hidden = name !== "observer"; byId("observer-screen").hidden = name !== "observer";
documentObject.body.classList.toggle("match-active", name === "match");
} }
function renderWelcomeHeadline(headline) { function renderWelcomeHeadline(headline) {
@@ -661,10 +662,7 @@
} }
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); } if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result); else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else { else hideModal();
hideModal();
if (!state.onboardingSeen) showOnboarding(0);
}
} }
function applyObserverTransform() { function applyObserverTransform() {