diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index e0b6ff4..085cbda 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -19,3 +19,17 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); expect(after).not.toBe(before); }); + +// The document-pin that stops the SPA from rubber-banding is scoped to the game app (main.ts +// adds .app-shell); the landing is a normal scrolling document and must keep scrolling. +test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => { + await page.goto('/landing.html'); + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + + const state = await page.evaluate(() => ({ + shell: document.documentElement.classList.contains('app-shell'), + bodyPosition: getComputedStyle(document.body).position, + })); + expect(state.shell).toBe(false); + expect(state.bodyPosition).not.toBe('fixed'); +}); diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 041b507..81900fc 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -28,3 +28,20 @@ test('guest reaches a board and previews a placement', async ({ page }) => { // The contextual MakeMove control (✅) appears once a tile is pending. await expect(page.locator('.make')).toBeVisible(); }); + +// The SPA pins the document so iOS/WKWebView cannot rubber-band the whole page on a vertical +// drag (the elastic bounce that fought the board's swipe-to-open-history). The native bounce +// itself is not reproducible in Playwright, so we assert the CSS contract that suppresses it. +test('the app pins the document so the page cannot rubber-band', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); + + const lock = await page.evaluate(() => ({ + shell: document.documentElement.classList.contains('app-shell'), + htmlOverflow: getComputedStyle(document.documentElement).overflowY, + bodyPosition: getComputedStyle(document.body).position, + })); + expect(lock.shell).toBe(true); + expect(lock.htmlOverflow).toBe('hidden'); + expect(lock.bodyPosition).toBe('fixed'); +}); diff --git a/ui/src/app.css b/ui/src/app.css index 9498f8e..de3930f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -140,6 +140,20 @@ body { touch-action: manipulation; } +/* The game SPA (main.ts adds .app-shell to ) pins the document so iOS/WKWebView — notably + the Telegram Mini App — cannot rubber-band ("stretch") the whole page on a vertical drag; + overscroll-behavior alone does not stop the root-document bounce there. Every screen fits the + visual viewport (--vvh) and scrolls its own inner areas, so the document never needs to + scroll. The standalone landing page (landing.ts) omits the class and scrolls normally. */ +html.app-shell { + overflow: hidden; +} +html.app-shell body { + position: fixed; + inset: 0; + overflow: hidden; +} + #app { height: 100%; /* No text selection anywhere by default; inputs opt back in below. */ diff --git a/ui/src/main.ts b/ui/src/main.ts index dd41642..af36848 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -2,4 +2,9 @@ import { mount } from 'svelte'; import './app.css'; import App from './App.svelte'; +// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably +// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone +// landing page (landing.ts) is a normal scrolling document and deliberately omits this class. +document.documentElement.classList.add('app-shell'); + export default mount(App, { target: document.getElementById('app')! });