From 9277a70565db75e42d2a89bf738926cd88644d65 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:19:16 +0200 Subject: [PATCH 1/3] UI: drop tab-bar tap highlight; don't slide the first screen on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab bar: tapping a bottom-tab icon flashed a background — the icon square's :active press tint plus the default WebKit tap flash, the same pair removed from the lobby rows. Drop the press tint and set -webkit-tap-highlight-color: transparent on .tab. The selected-tab highlight (Settings / Comms hubs) stays. Startup slide: the route pane's in:slideX is local to its {#key} block, so it plays on that block's own first mount when app.ready flips — the lobby slid in on launch as if navigated into from another screen. Gate the slide duration to 0 for the first pane shown after boot (a `started` flag set right after it mounts), so launch is static while every later route change animates as before. --- ui/src/App.svelte | 15 ++++++++++++--- ui/src/components/TabBar.svelte | 12 +++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e42fb6..3917034 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -32,8 +32,9 @@ // 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). Transitions are local, so - // they do not play on the initial mount, and collapse to nothing under reduce-motion. + // 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. // 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 @@ -52,7 +53,15 @@ const enterSign = $derived(dir === 'forward' ? 1 : -1); const leaveSign = $derived(dir === 'forward' ? -1 : 1); const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); - const animMs = $derived(app.reduceMotion ? 0 : 260); + // 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); // 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/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); -- 2.52.0 From c32a15730a13ea88e8e4ba016eaa18908db726ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:40:40 +0200 Subject: [PATCH 2/3] UI: fix the lobby slide on Telegram cold launch (correct the cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first attempt (the App.svelte `started` gate) targeted the first pane mount, but the slide is a second render. On a Telegram cold launch the URL fragment is Telegram's #tgWebAppData=... launch params, which the router parsed as notfound; bootstrap's navigate('/') then corrected it to the lobby asynchronously, re-keying the route pane (notfound -> lobby) and sliding the lobby in as if returning from a screen. A reload was static because the hash was already #/. Treat a Telegram launch fragment as the lobby root in the router, so the route is correct from the first pane (no re-key, no slide). Extract the pure hash->Route parsing into routeparse.ts so it unit-tests without a DOM, and revert the gate (the first pane never slid — local transitions skip the initial mount, as clean browser launches showed). Tests: routeparse unit tests (incl. the tgWebApp fragment); an e2e that launches with the fragment in the URL and asserts the lobby plus the normalised #/ hash. --- ui/e2e/telegram.spec.ts | 17 ++++++++++ ui/src/App.svelte | 15 ++------- ui/src/lib/routeparse.test.ts | 34 +++++++++++++++++++ ui/src/lib/routeparse.ts | 61 +++++++++++++++++++++++++++++++++++ ui/src/lib/router.svelte.ts | 51 +++-------------------------- 5 files changed, 119 insertions(+), 59 deletions(-) create mode 100644 ui/src/lib/routeparse.test.ts create mode 100644 ui/src/lib/routeparse.ts 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 : ''), -- 2.52.0 From 390b4c756f78519b2994dd7ddb5af38a06aefeb0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:51:39 +0200 Subject: [PATCH 3/3] UI: soften the board checkerboard's dark cells The gapless-board dark cells mixed 12% black into --cell-bg, which read too contrasty and competed visually with the bonus cells. Halve the tint to 6% (both themes, keyed off --cell-bg as before) for a gentler checkerboard. --- ui/src/game/Board.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 { -- 2.52.0