From 8c55b2d2396cd5e751e0405b25596b3f0cc51d13 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 12:30:47 +0200 Subject: [PATCH 1/7] chore(ui): temporary on-device boot diagnostic for the Android WebView white screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a classic ES5 +
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 53258e6..f4d0d8a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,7 +1,25 @@ import { resolve } from 'node:path'; -import { defineConfig } from 'vite'; +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. + */ +function stripBootDiag(): Plugin { + return { + name: 'strip-boot-diag', + transformIndexHtml(html) { + return html.replace(/\s*/g, ''); + }, + }; +} + // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // not speak h2c directly) talks to the dev server on the same origin. In `mock` @@ -19,7 +37,10 @@ export default defineConfig(({ mode }) => ({ // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), }, - plugins: [svelte()], + // 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(), ...(mode !== 'production' ? [stripBootDiag()] : [])], server: { port: 5173, proxy: -- 2.52.0 From 6dfd5203d78c75c958fc81e0cd39664b0c40e025 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 12:58:57 +0200 Subject: [PATCH 2/7] fix(ui): down-level build target to es2019 for old Android WebViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The es2022 build target shipped optional chaining / nullish coalescing verbatim (esbuild does not down-level syntax below the configured target), so an old Android System WebView — captured as Chromium 66 in the Telegram/VK in-app WebView on Android 9, via the boot diagnostic — rejected the bundle with "Uncaught SyntaxError: Unexpected token ?" at main-*.js:2, the module never ran (no .app-shell, #app empty) and the SPA showed a white screen. Firefox/Gecko on the same device, which ships its own current engine, rendered the SPA fine. Lower build.target to es2019 so esbuild down-levels the es2020+ syntax the old engine cannot parse (?., ??, private fields, static blocks, numeric separators). Verified the emitted main bundle no longer contains ?./?? and the mock e2e (Chromium + WebKit) still passes. The boot-diagnostic JS-SYNTAX header is relabelled accordingly. esbuild lowers syntax only, not runtime APIs; the app's own production source uses none beyond this floor (mock-only helpers are tree-shaken). A graceful "update your WebView" fallback and any runtime-API polyfills follow once the on-device re-test confirms the parse fix (and @vitejs/plugin-legacy stays in reserve for even older engines). --- ui/index.html | 2 +- ui/vite.config.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/index.html b/ui/index.html index f1f5282..9d150d6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -162,7 +162,7 @@ ['numeric separator 1_000', 'return 1_000', 75], ['dynamic import()', 'return import("")', 63], ]; - var synPre = section('JS SYNTAX (esbuild target is es2022 — the bundle uses these)'); + var synPre = section('JS SYNTAX (engine feature ceiling — first NO dates the engine)'); function mark(ok) { return ok ? 'OK ' : 'NO '; } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f4d0d8a..817abff 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -54,7 +54,13 @@ export default defineConfig(({ mode }) => ({ }, }, build: { - target: 'es2022', + // Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on + // Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private + // fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA + // on a white screen. esbuild lowers syntax only, not runtime APIs; the app's own source uses + // none beyond this floor (the mock-only helpers are tree-shaken from production). Reach for + // @vitejs/plugin-legacy if runtime-API polyfills for even older engines turn out to be needed. + target: 'es2019', // Emit sourcemaps everywhere except the production build. A shipped `.map` // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the // gateway/landing images serve `dist/` verbatim, so production maps would expose -- 2.52.0 From 4a0c296bbb669c62930c1911031bd7ec994b9824 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 17:03:49 +0200 Subject: [PATCH 3/7] fix(ui): conditionally load core-js polyfills for old Android WebViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The es2019 down-level fixed the parse error, but the Chromium 66 in-app WebView (Android 9) then threw "Uncaught ReferenceError: globalThis is not defined" — esbuild lowers syntax, not the es2020+ runtime globals the bundle and its deps reference (globalThis, structuredClone, Array.at, Object.hasOwn, Promise.allSettled, …), so the module still failed to evaluate and the SPA stayed a white screen. Load core-js only where it is actually missing. A permanent ES5 gate in index.html feature-detects the engine and, on an old one, document.writes a parser-blocking diff --git a/ui/package.json b/ui/package.json index 11fa26e..9138db1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "@playwright/test": "^1.49.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@types/node": "^22.10.0", + "core-js-bundle": "^3.49.0", "svelte": "^5.15.0", "svelte-check": "^4.1.0", "typescript": "^5.7.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 5f813f2..8b4434c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.19 + core-js-bundle: + specifier: ^3.49.0 + version: 3.49.0 svelte: specifier: ^5.15.0 version: 5.56.0 @@ -508,6 +511,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + core-js-bundle@3.49.0: + resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1132,6 +1138,8 @@ snapshots: clsx@2.1.1: {} + core-js-bundle@3.49.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 34a05e7..14571e3 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -1,4 +1,8 @@ # pnpm 11 records build-script approval here. esbuild's postinstall materialises # its CLI shim; the platform binary itself ships as an optional dependency. +# core-js-bundle ships a prebuilt minified.js (we only read that file at build time, +# via vite.config emitPolyfills) and its install script is just a funding banner, so +# it is denied — nothing to build. allowBuilds: + core-js-bundle: false esbuild: true diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 817abff..79ab0ac 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { defineConfig, type Plugin } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; @@ -20,6 +21,24 @@ function stripBootDiag(): Plugin { }; } +/** + * emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate + * script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the + * app uses (globalThis, structuredClone, Array.at, …) — e.g. the Chromium 66 Android System WebView + * behind the Telegram/VK in-app browser — before the deferred module runs. A modern engine never + * requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size + * gate measures. + */ +function emitPolyfills(): Plugin { + return { + name: 'emit-polyfills', + generateBundle() { + const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8'); + this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src }); + }, + }; +} + // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // not speak h2c directly) talks to the dev server on the same origin. In `mock` @@ -37,10 +56,11 @@ export default defineConfig(({ mode }) => ({ // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), }, - // 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(), ...(mode !== 'production' ? [stripBootDiag()] : [])], + // 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()] : [])], server: { port: 5173, proxy: @@ -57,9 +77,10 @@ export default defineConfig(({ mode }) => ({ // Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on // Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private // fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA - // on a white screen. esbuild lowers syntax only, not runtime APIs; the app's own source uses - // none beyond this floor (the mock-only helpers are tree-shaken from production). Reach for - // @vitejs/plugin-legacy if runtime-API polyfills for even older engines turn out to be needed. + // on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the + // bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately + // by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an + // engine that actually lacks them. target: 'es2019', // Emit sourcemaps everywhere except the production build. A shipped `.map` // carries full `sourcesContent` — the entire TypeScript/Svelte source — and the -- 2.52.0 From de2f1432be6def33a3812ab2c54ce35c4ee000a4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 17:40:31 +0200 Subject: [PATCH 4/7] chore(ui): surface caught boot errors on the diagnostic panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The es2019 + conditional core-js fixes let the module mount on the Chromium 66 in-app WebView, but the lobby then shows a white screen and a generic "something went wrong" toast — a caught error that never reaches window.onerror, so the diagnostic ERRORS panel stayed empty and could not name the cause. Mirror console.error / console.warn into the panel, and add a temporary, mock-gated console.error of the raw error (with stack) in handleError, so the on-device panel shows exactly what threw and whether it is one error or several. Expected: a ReferenceError from the 64-bit FlatBuffers timestamp decode (BigInt, absent on Chrome 66 and not polyfillable by core-js) — to be confirmed on-device before deciding the support floor. Temporary — both hunks revert together with the index.html BOOT-DIAG block. --- ui/index.html | 28 ++++++++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 6 ++++++ 2 files changed, 34 insertions(+) diff --git a/ui/index.html b/ui/index.html index 28c2bbd..cc2c2f8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -143,6 +143,34 @@ var r = e && e.reason; logErr('unhandledrejection', r && (r.stack || r.message) ? r.stack || r.message : String(r)); }); + // Mirror console.error / console.warn into the panel. The app catches its boot failures and + // surfaces them only as a toast (they never reach window.onerror); a temporary console.error + // at the app's handleError routes the real cause here, so the panel shows what actually threw. + function fmt(a) { + if (a && (a.stack || a.message)) return a.stack || a.message; + if (typeof a === 'object') { + try { + return JSON.stringify(a); + } catch (e) { + return String(a); + } + } + return String(a); + } + function wrapConsole(orig, label) { + return function () { + try { + var parts = []; + for (var i = 0; i < arguments.length; i++) parts.push(fmt(arguments[i])); + logErr('console.' + label, parts.join(' ')); + } catch (e) {} + if (orig) return orig.apply(console, arguments); + }; + } + if (window.console) { + console.error = wrapConsole(console.error, 'error'); + console.warn = wrapConsole(console.warn, 'warn'); + } // ---- environment ----------------------------------------------------------------------- var ua = nav.userAgent || '(no userAgent)'; diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 07be39e..8e86829 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -283,6 +283,12 @@ 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(); -- 2.52.0 From c353c036bac80fe4f7726a9b7c3575bba2f13e92 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 18:43:52 +0200 Subject: [PATCH 5/7] fix(ui): draw board tile glyphs on Chrome < 105 (container-query-unit fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board sizes its tile letter/value glyphs (and the blank/bonus marks) in container- query units (cqw), which are Chrome 105+. On an older engine — e.g. the Chrome 74 Android 10 System WebView — the cqw declaration is invalid and dropped, so the glyph inherits the .cell `font-size: 0` reset and renders at 0px: the board tiles show empty while the rack and stats tiles (not under that reset) render fine. Add a viewport-relative fallback before each cqw font-size — calc(vmin * var(--z, 1)) — which approximates the board-relative size and tracks the zoom (--z), so old WebViews draw the glyphs; Chrome 105+ still takes the exact cqw line after it. Additive only, no change on modern engines (verified: mock e2e incl. the board/zoom specs still green). The landscape board-container sizing (.scaler.land min(100cqw,100cqh)) still relies on container-query units and is not covered here — portrait (the mobile default under test) is unaffected by that. --- ui/src/game/Board.svelte | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7288220..3e94fa3 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -479,6 +479,11 @@ position: absolute; top: 5%; left: 8%; + /* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell + `font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks + the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System + WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */ + font-size: calc(4.2vmin * var(--z, 1)); font-size: 4.2cqw; font-weight: 700; line-height: 1; @@ -487,12 +492,14 @@ position: absolute; right: 5%; bottom: 3%; + font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.4cqw; font-weight: 600; } /* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits, its ink centred on the same line as a neighbouring tile's value digit. */ .blankmark { + font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.8cqw; bottom: 0; } @@ -501,6 +508,7 @@ inset: 0; display: grid; place-items: center; + font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 3.6cqw; opacity: 0.7; } @@ -509,6 +517,7 @@ inset: 0; display: grid; place-items: center; + font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 2.7cqw; font-weight: 600; opacity: 0.9; @@ -526,10 +535,12 @@ padding: 0 1px; } .bt { + font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 1.7cqw; font-weight: 600; } .bb { + font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ font-size: 1.9cqw; font-weight: 700; white-space: nowrap; -- 2.52.0 From 8499f388af8c1b8a1361825589acbc570fd865a5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 19:08:48 +0200 Subject: [PATCH 6/7] fix(ui): pan the zoomed board via window listeners, not pointer capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mouse drag-to-pan of the zoomed board captured the pointer on the scroll container (vp.setPointerCapture) and wrote that same element's scrollLeft/Top. On an older Chromium — the Chrome 74 Android 10 System WebView — capturing the pointer on the very element being scrolled drops or displaces the capture as the scroll offset changes mid-drag, so the board juddered under the cursor and would not pan (modern engines cope). Track the drag with window pointermove/up/cancel listeners, added on pointerdown and removed on release, instead of capturing — the same "keep receiving moves past the element edge" without holding the scroll container. The path is mouse-only (touch scrolls the overflow viewport natively) and its behaviour on modern engines is unchanged; svelte-check and the mock e2e (incl. the zoom specs) are green. Speculative, not locally reproducible (needs Chrome 74): pending the owner's on-device confirmation. The unrelated auto-zoom-on-placement gap on the same engine is left as-is (minor). --- ui/src/game/Board.svelte | 44 +++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 3e94fa3..0428c5d 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -228,16 +228,11 @@ let sy = 0; let sl = 0; let st = 0; - const onDown = (e: PointerEvent) => { - if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return; - if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return; - armed = true; - panning = false; - sx = e.clientX; - sy = e.clientY; - sl = vp.scrollLeft; - st = vp.scrollTop; - }; + // 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; @@ -245,16 +240,17 @@ 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) => { + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); 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) => { @@ -264,15 +260,25 @@ 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; + armed = true; + panning = false; + sx = e.clientX; + sy = e.clientY; + sl = vp.scrollLeft; + st = vp.scrollTop; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }; vp.addEventListener('pointerdown', onDown); - vp.addEventListener('pointermove', onMove); - vp.addEventListener('pointerup', onUp); - vp.addEventListener('pointercancel', onUp); return () => { vp.removeEventListener('pointerdown', onDown); - vp.removeEventListener('pointermove', onMove); - vp.removeEventListener('pointerup', onUp); - vp.removeEventListener('pointercancel', onUp); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); }; }); -- 2.52.0 From 2729fe9467d87f20c948b4a9065c7a6d4b62a309 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 20:23:19 +0200 Subject: [PATCH 7/7] feat(ui): unsupported-engine boot screen; drop the temporary diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the temporary on-device boot diagnostic (the always-on overlay, the console.error mirror and the handleError console hook) with a permanent boot-capability guard in index.html. Before the deferred module, in plain ES5, it: - hard-gates on the unpolyfillable essentials the app cannot run without — BigInt (the 64-bit FlatBuffers wire decode) and Proxy (Svelte 5 runes) — and, when one is missing, shows a friendly full-screen "this device's OS/browser can't run the app" screen instead of a white screen; - soft-gates the polyfillable es2020+ globals, pulling core-js (polyfills.js) only when needed (the previous behaviour, folded in); - keeps a reactive net: an uncaught error during boot with no window.__booted signal within 8s raises the same screen with the captured cause. Inside a Telegram/VK Mini App the screen also points to the web version (https:///app/). A "Diagnostic information" button reveals the engine, the feature table, the reason and the app version, with a Copy button. App.svelte sets window.__booted once bootstrap resolves. vite.config drops the now-unneeded strip-boot-diag plugin and adds inject-boot-version (stamps the guard's diagnostic with VITE_APP_VERSION). The es2019 target, the core-js loader and the board cqw->vmin glyph fallback stay. The speculative Chrome-74 pan change is reverted — it did not fix the judder and added nothing on modern engines. Verified: svelte-check clean, unit + mock e2e green (the guard is inert on a capable engine, so the size budget and the smoke are untouched), and the screen + diagnostic + stamped version render on a forced hard-gate (BigInt removed via addInitScript). The unsupported-engine telemetry beacon (dedup + edge rate-limit + a Prometheus counter) is a separate follow-up PR, per the agreed split. --- ui/index.html | 539 +++++++++++++++------------------------ ui/src/App.svelte | 7 +- ui/src/game/Board.svelte | 70 +++-- ui/src/lib/app.svelte.ts | 6 - ui/vite.config.ts | 26 +- 5 files changed, 248 insertions(+), 400 deletions(-) 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: -- 2.52.0