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/components/TabBar.svelte b/ui/src/components/TabBar.svelte index b6ebbb3..d29a1ad 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -34,12 +34,13 @@ width: 100%; user-select: none; -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; /* no WebKit flash on tap */ } :global(.tab:disabled) { opacity: 0.4; } - /* The icon square hugs the emoji (just a little padding) so it is the press-highlight - target and the badge can sit on its corner. */ + /* The icon square hugs the emoji (just a little padding) so the badge can sit on its + corner. */ :global(.tab .sq) { position: relative; display: inline-grid; @@ -50,12 +51,9 @@ line-height: 1; transition: background-color 0.12s; } - :global(.tab:active:not(:disabled) .sq) { - background: var(--surface-2); - } /* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted - while selected: a filled square with an accent underline, distinct from the - momentary press tint above. */ + while selected: a filled square with an accent underline. A tap itself leaves no + highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */ :global(.tab.active .sq) { background: var(--surface-2); box-shadow: inset 0 -2px 0 var(--accent); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index cb9289e..ffb2e89 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -297,7 +297,9 @@ border-radius: 0; } .grid.gridless .cell.dark { - background: color-mix(in srgb, var(--cell-bg) 88%, #000); + /* A gentle checkerboard tint: enough to read the alternation without competing with the + bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */ + background: color-mix(in srgb, var(--cell-bg) 94%, #000); } .grid.gridless .cell.filled, .grid.gridless .cell.pending { 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 : ''),