diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index 8a1188a..dbc3e00 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -35,6 +35,23 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme', .toBe('#101418'); }); +test('a Telegram launch fragment in the URL still lands on the lobby and normalises the hash', async ({ + page, +}) => { + await page.addInitScript((stub) => { + Object.assign(window, stub); + }, webAppStub()); + // Telegram appends its launch params to the URL fragment on a cold launch; the router must + // not treat that as a route (it parsed as notfound, which re-keyed the pane and slid the + // lobby in as if returning from another screen). + await page.goto( + '/#tgWebAppData=query_id%3Dtest%26user%3D%257B%2522id%2522%253A1%257D&tgWebAppVersion=7.0&tgWebAppPlatform=ios', + ); + await expect(page.getByText('Your turn')).toBeVisible(); + // The lobby is the root: bootstrap normalised the launch-param fragment to '#/'. + await expect.poll(() => new URL(page.url()).hash).toBe('#/'); +}); + test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 3917034..9e42fb6 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -32,9 +32,8 @@ // Screen transitions: the lobby is the navigation root. Entering a screen from the // lobby slides it in from the right (forward); returning to the lobby slides the - // screen out to the right and reveals the lobby (back). The first pane shown after boot - // does not slide (the `started` gate below), and transitions collapse to nothing under - // reduce-motion. + // screen out to the right and reveals the lobby (back). Transitions are local, so + // they do not play on the initial mount, and collapse to nothing under reduce-motion. // Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward, // returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it @@ -53,15 +52,7 @@ const enterSign = $derived(dir === 'forward' ? 1 : -1); const leaveSign = $derived(dir === 'forward' ? -1 : 1); const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); - // The first pane shown once the app is ready must not slide: it would look like the app - // was navigated into rather than launched. The pane is a local transition inside a {#key} - // block, so it plays on that block's own first mount anyway — hold the duration at 0 until - // just after that mount (the effect runs post-render), then animate every later change. - let started = $state(false); - $effect(() => { - if (app.ready) started = true; - }); - const animMs = $derived(!started || app.reduceMotion ? 0 : 260); + const animMs = $derived(app.reduceMotion ? 0 : 260); // slideX slides a pane horizontally by a full width. sign>0 enters from / exits to // the right; sign<0 the left. Percentage keeps it viewport-relative without reading diff --git a/ui/src/lib/routeparse.test.ts b/ui/src/lib/routeparse.test.ts new file mode 100644 index 0000000..14f5383 --- /dev/null +++ b/ui/src/lib/routeparse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from './routeparse'; + +describe('parse', () => { + it('maps the empty hash and the root to the lobby', () => { + expect(parse('').name).toBe('lobby'); + expect(parse('#/').name).toBe('lobby'); + }); + + it('maps known paths to their routes', () => { + expect(parse('#/login').name).toBe('login'); + expect(parse('#/new').name).toBe('new'); + expect(parse('#/settings').name).toBe('settings'); + expect(parse('#/stats').name).toBe('stats'); + expect(parse('#/game/abc')).toEqual({ name: 'game', params: { id: 'abc' } }); + expect(parse('#/game/abc/chat')).toEqual({ name: 'gameChat', params: { id: 'abc' } }); + expect(parse('#/game/abc/check')).toEqual({ name: 'gameCheck', params: { id: 'abc' } }); + }); + + it('maps an unknown path and a game without an id to notfound', () => { + expect(parse('#/bogus').name).toBe('notfound'); + expect(parse('#/game').name).toBe('notfound'); + }); + + it('treats a Telegram launch fragment as the lobby root, not notfound', () => { + // Telegram appends its Mini App launch params to the URL fragment on a cold launch; they + // are launch metadata, not a route. Parsing them as notfound made bootstrap's navigate('/') + // re-key the route pane (notfound -> lobby) and slide the lobby in on launch. + expect(parse('#tgWebAppData=query_id%3Dx&tgWebAppVersion=7.0&tgWebAppPlatform=ios').name).toBe( + 'lobby', + ); + expect(parse('#tgWebAppStartParam=foo').name).toBe('lobby'); + }); +}); diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts new file mode 100644 index 0000000..8e4146b --- /dev/null +++ b/ui/src/lib/routeparse.ts @@ -0,0 +1,61 @@ +// Pure hash-path -> Route parsing for the router. Kept dependency-free and free of any +// reactive state so it unit-tests in the node environment; router.svelte.ts wraps it with +// the reactive route rune and navigation. + +export type RouteName = + | 'login' + | 'lobby' + | 'new' + | 'game' + | 'gameChat' + | 'gameCheck' + | 'profile' + | 'settings' + | 'about' + | 'friends' + | 'stats' + | 'notfound'; + +export interface Route { + name: RouteName; + params: Record; +} + +/** + * parse maps a location hash to a Route. An empty hash is the lobby root. A Telegram Mini + * App cold launch appends its launch params to the URL fragment (#tgWebAppData=...& + * tgWebAppVersion=...); those are launch metadata, not a route, so the fragment is treated + * as the lobby root. Otherwise it would parse as notfound, and bootstrap's navigate('/') + * would then re-key the route pane (notfound -> lobby), sliding the lobby in on launch as if + * returning from another screen. + */ +export function parse(hash: string): Route { + const raw = hash.replace(/^#/, ''); + if (raw === '' || raw.startsWith('tgWebApp')) return { name: 'lobby', params: {} }; + const path = raw.split('?')[0]; + const seg = path.split('/').filter(Boolean); + if (seg.length === 0) return { name: 'lobby', params: {} }; + switch (seg[0]) { + case 'login': + return { name: 'login', params: {} }; + case 'new': + return { name: 'new', params: {} }; + case 'game': + if (!seg[1]) return { name: 'notfound', params: {} }; + if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } }; + if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } }; + return { name: 'game', params: { id: seg[1] } }; + case 'profile': + return { name: 'profile', params: {} }; + case 'settings': + return { name: 'settings', params: {} }; + case 'about': + return { name: 'about', params: {} }; + case 'friends': + return { name: 'friends', params: {} }; + case 'stats': + return { name: 'stats', params: {} }; + default: + return { name: 'notfound', params: {} }; + } +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index 2d90df4..3e62dde 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -1,54 +1,11 @@ // Minimal dependency-free hash router. Hash routing survives a reload and works on // a file:// origin (Capacitor native packaging), where there is no server to honour -// deep paths. The route is a reactive rune so screens re-render on navigation. +// deep paths. The route is a reactive rune so screens re-render on navigation. The pure +// hash->Route parsing lives in routeparse.ts (unit-tested without a DOM). -export type RouteName = - | 'login' - | 'lobby' - | 'new' - | 'game' - | 'gameChat' - | 'gameCheck' - | 'profile' - | 'settings' - | 'about' - | 'friends' - | 'stats' - | 'notfound'; +import { parse, type Route, type RouteName } from './routeparse'; -export interface Route { - name: RouteName; - params: Record; -} - -function parse(hash: string): Route { - const path = (hash.replace(/^#/, '') || '/').split('?')[0]; - const seg = path.split('/').filter(Boolean); - if (seg.length === 0) return { name: 'lobby', params: {} }; - switch (seg[0]) { - case 'login': - return { name: 'login', params: {} }; - case 'new': - return { name: 'new', params: {} }; - case 'game': - if (!seg[1]) return { name: 'notfound', params: {} }; - if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } }; - if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } }; - return { name: 'game', params: { id: seg[1] } }; - case 'profile': - return { name: 'profile', params: {} }; - case 'settings': - return { name: 'settings', params: {} }; - case 'about': - return { name: 'about', params: {} }; - case 'friends': - return { name: 'friends', params: {} }; - case 'stats': - return { name: 'stats', params: {} }; - default: - return { name: 'notfound', params: {} }; - } -} +export type { Route, RouteName }; export const router = $state<{ route: Route }>({ route: parse(typeof location !== 'undefined' ? location.hash : ''),