From 30a7c24140958f96e627ee04d26535ab1b756645 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:52:52 +0200 Subject: [PATCH 1/2] fix(ui): interface language follows the device, not the Telegram bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On login the UI no longer overrides the interface language from the account's preferred_language. The live interface follows the device — the explicit local choice (saved, locked) or the system-language guess — so opening the mini-app via the ru-bot on an English system keeps the interface English (it was forced to Russian by the account seed). preferred_language is still written from Settings and used for out-of-app push routing; it just no longer dictates the UI on launch. --- ui/src/lib/app.svelte.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a079f9f..69e2d27 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -341,7 +341,11 @@ async function adoptSession(s: Session): Promise { await saveSession(s); try { app.profile = await gateway.profileGet(); - if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale)); + // The live interface language follows the device — the explicit local choice (locked, saved + // in prefs) or the system guess made at bootstrap — and is no longer overridden from the + // account here. preferred_language stays the user's saved choice (written from Settings, + // and used for out-of-app push routing), but the Telegram bot a user signs in through must + // not dictate the UI: a ru-bot launch on an English system stays English. } catch (err) { handleError(err); } From 800a6927660a4f3d275f54b691cd30c9333f99de Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:52:52 +0200 Subject: [PATCH 2/2] fix(ui): a board pinch-zoom no longer triggers swipe-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge swipe-back armed on the first finger and fired on release even when a second finger had joined (a pinch-zoom — the board is not .zoomed yet at the first touch, so the hit-test could not skip it). Track the live pointer count in the capture phase and cancel the swipe the moment a second pointer joins; the back navigation fires only for a lone finger. Synthetic-PointerEvent e2e covers both. --- ui/e2e/gestures.spec.ts | 45 +++++++++++++++++++++++++++++ ui/src/components/Screen.svelte | 51 ++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 ui/e2e/gestures.spec.ts diff --git a/ui/e2e/gestures.spec.ts b/ui/e2e/gestures.spec.ts new file mode 100644 index 0000000..ecf9f10 --- /dev/null +++ b/ui/e2e/gestures.spec.ts @@ -0,0 +1,45 @@ +import { expect, test, type Page } from './fixtures'; + +// The edge swipe-back gesture (components/Screen.svelte) against the mock transport. +// Real multi-touch pinch is not reproducible in Playwright, so this drives the handler +// directly with synthetic PointerEvents (it listens at the window in the capture phase): +// a lone finger swiping right from the left edge navigates back; a two-finger gesture +// (a pinch-zoom, whose release would otherwise read as a swipe) does not. + +async function loginLobby(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); +} + +function pointer(page: Page, type: string, x: number, y: number, id: number): Promise { + return page.evaluate( + ({ type, x, y, id }) => { + window.dispatchEvent( + new PointerEvent(type, { clientX: x, clientY: y, pointerId: id, pointerType: 'touch', bubbles: true, cancelable: true }), + ); + }, + { type, x, y, id }, + ); +} + +test('edge swipe-back: a lone finger navigates back, a pinch does not', async ({ page }) => { + await loginLobby(page); + + // Open the Settings hub (back = lobby). + await page.getByRole('button', { name: /Settings/ }).click(); + await expect(page.getByText('Your turn')).toBeHidden(); + + // A two-finger gesture from the left edge is a pinch (its second finger joins after the + // first): releasing it must NOT navigate back — we stay on Settings. + await pointer(page, 'pointerdown', 8, 300, 1); + await pointer(page, 'pointerdown', 60, 300, 2); + await pointer(page, 'pointerup', 260, 300, 1); + await pointer(page, 'pointerup', 90, 300, 2); + await expect(page.getByText('Your turn')).toBeHidden(); + + // A single finger swiping right from the left edge does return to the lobby. + await pointer(page, 'pointerdown', 8, 300, 1); + await pointer(page, 'pointerup', 260, 300, 1); + await expect(page.getByText('Your turn')).toBeVisible(); +}); diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index 444c6bd..99128f8 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -42,22 +42,53 @@ // a zoomed-in board (it pans), and text inputs. const EDGE_FRACTION = 0.5; const SWIPE_SKIP = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select'; + // A pinch-zoom on the board is two fingers; its second finger lands after the gesture began + // (the board is not yet .zoomed at the first touch, so the hit-test above can't skip it), and + // releasing it would otherwise read as a horizontal back-swipe. Track the live pointer count + // (in the capture phase, so we see every touch even if the board stops propagation) and cancel + // the swipe the moment a second finger joins. The swipe fires only for a lone finger. $effect(() => { + let startX = 0; + let startY = 0; + let active = 0; // pointers currently down + let tracking = false; // a candidate single-finger edge swipe is in progress + let pinched = false; // a second finger joined — not a back-swipe + function onDown(e: PointerEvent) { + active++; + if (active > 1) { + pinched = true; + return; + } + tracking = false; + pinched = false; if (!back || e.pointerType === 'mouse' || e.clientX > window.innerWidth * EDGE_FRACTION) return; if (document.elementFromPoint(e.clientX, e.clientY)?.closest(SWIPE_SKIP)) return; - const x0 = e.clientX; - const y0 = e.clientY; - const onUp = (ev: PointerEvent) => { - window.removeEventListener('pointerup', onUp, true); - const dx = ev.clientX - x0; - const dy = ev.clientY - y0; - if (back && dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back); - }; - window.addEventListener('pointerup', onUp, true); + startX = e.clientX; + startY = e.clientY; + tracking = true; } + + function onUp(e: PointerEvent) { + if (active > 0) active--; + if (active > 0) return; // wait until every finger has lifted + if (tracking && !pinched && back) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + if (dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back); + } + tracking = false; + pinched = false; + } + window.addEventListener('pointerdown', onDown, true); - return () => window.removeEventListener('pointerdown', onDown, true); + window.addEventListener('pointerup', onUp, true); + window.addEventListener('pointercancel', onUp, true); + return () => { + window.removeEventListener('pointerdown', onDown, true); + window.removeEventListener('pointerup', onUp, true); + window.removeEventListener('pointercancel', onUp, true); + }; });