diff --git a/ui/index.html b/ui/index.html index cc2c2f8..be6efac 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,30 +2,207 @@ - + - -
diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ad3f36d..39bd17e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -26,7 +26,12 @@ import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; onMount(() => { - void bootstrap(); + // Tell the index.html boot guard that startup completed, so its reactive net does not raise the + // unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred + // AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility). + void bootstrap().then(() => { + (window as unknown as { __booted?: boolean }).__booted = true; + }); }); // The lobby is the cold-start landing (an empty hash and Telegram launch params both parse diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 0428c5d..3e94fa3 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -228,38 +228,6 @@ let sy = 0; let sl = 0; let st = 0; - // Track the drag on window rather than vp.setPointerCapture. Capturing the pointer on the very - // element being scrolled makes an older Chromium — the Chrome 74 Android 10 System WebView — - // drop or displace the capture as scrollLeft/Top are written mid-drag, so the board judders and - // will not pan. Window listeners give the same "keep receiving moves past the element edge" - // without capturing the scroll container; modern engines behave identically. - const onMove = (e: PointerEvent) => { - if (!armed) return; - const dx = e.clientX - sx; - const dy = e.clientY - sy; - if (!panning) { - if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan - panning = true; - } - vp.scrollLeft = sl - dx; - vp.scrollTop = st - dy; - }; - const onUp = () => { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - armed = false; - if (!panning) return; - panning = false; - // Swallow the click the drag would otherwise produce (capture phase, before it reaches the - // cell); self-remove on that click, and clean up on the next tick if none fired. - const swallow = (ev: Event) => { - ev.stopPropagation(); - vp.removeEventListener('click', swallow, true); - }; - vp.addEventListener('click', swallow, true); - setTimeout(() => vp.removeEventListener('click', swallow, true), 0); - }; const onDown = (e: PointerEvent) => { if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return; if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return; @@ -269,16 +237,42 @@ sy = e.clientY; sl = vp.scrollLeft; st = vp.scrollTop; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); + }; + const onMove = (e: PointerEvent) => { + if (!armed) return; + const dx = e.clientX - sx; + const dy = e.clientY - sy; + if (!panning) { + if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan + panning = true; + vp.setPointerCapture(e.pointerId); + } + vp.scrollLeft = sl - dx; + vp.scrollTop = st - dy; + }; + const onUp = (e: PointerEvent) => { + armed = false; + if (!panning) return; + panning = false; + if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId); + // Swallow the click the drag would otherwise produce (capture phase, before it reaches the + // cell); self-remove on that click, and clean up on the next tick if none fired. + const swallow = (ev: Event) => { + ev.stopPropagation(); + vp.removeEventListener('click', swallow, true); + }; + vp.addEventListener('click', swallow, true); + setTimeout(() => vp.removeEventListener('click', swallow, true), 0); }; vp.addEventListener('pointerdown', onDown); + vp.addEventListener('pointermove', onMove); + vp.addEventListener('pointerup', onUp); + vp.addEventListener('pointercancel', onUp); return () => { vp.removeEventListener('pointerdown', onDown); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); + vp.removeEventListener('pointermove', onMove); + vp.removeEventListener('pointerup', onUp); + vp.removeEventListener('pointercancel', onUp); }; }); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 8e86829..07be39e 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -283,12 +283,6 @@ export function markChatRead(gameId: string): void { * failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…" * header indicator (and auto-retried), never a red toast. */ export function handleError(err: unknown): void { - // TEMP (boot diagnostic): the toast is generic, so route the real caught error to the on-device - // BOOT-DIAG panel via console (it mirrors console.error). Mode-gated to keep it out of the mock - // e2e. Revert together with the index.html BOOT-DIAG block. - if (import.meta.env.MODE !== 'mock') { - console.error('[diag] handleError:', err instanceof Error ? err.stack || err.message : err); - } const code = err instanceof GatewayError ? err.code : ''; if (code === 'session_invalid' || code === 'unauthenticated') { void logout(); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 79ab0ac..3395ad0 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,19 +4,17 @@ import { defineConfig, type Plugin } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; /** - * stripBootDiag removes the temporary on-device boot-diagnostic block from index.html — the span - * between the `` and `` markers. That block is a - * full-screen ES5 overlay meant only for the production build shipped to the test contour, so it - * is dropped from every non-production build: the `mock` e2e (whose first taps the overlay would - * intercept) and the dev server. It is wired only when mode !== 'production'. Remove this plugin - * together with the index.html block once the Android WebView diagnosis is finished — it must - * never reach master / production. + * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its + * __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__. + * The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand + * diagnostic report name the client version anyway. Falls back to "dev" for a local build. */ -function stripBootDiag(): Plugin { +function injectBootVersion(): Plugin { + const version = process.env.VITE_APP_VERSION || 'dev'; return { - name: 'strip-boot-diag', + name: 'inject-boot-version', transformIndexHtml(html) { - return html.replace(/\s*/g, ''); + return html.replace(/__BOOT_VERSION__/g, version); }, }; } @@ -56,11 +54,9 @@ export default defineConfig(({ mode }) => ({ // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), }, - // emitPolyfills always ships dist/polyfills.js (loaded only by old engines; see its docstring - // and the index.html gate). The boot-diagnostic overlay ships only in the production build (the - // test contour) — it is stripped from the dev server and the mock e2e build, where a full-screen - // overlay would intercept Playwright's taps. See stripBootDiag / the index.html BOOT-DIAG block. - plugins: [svelte(), emitPolyfills(), ...(mode !== 'production' ? [stripBootDiag()] : [])], + // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the + // index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic. + plugins: [svelte(), emitPolyfills(), injectBootVersion()], server: { port: 5173, proxy: