UI: drop tab-bar tap highlight; don't slide the first screen on launch #45
@@ -35,6 +35,23 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
|
|||||||
.toBe('#101418');
|
.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 }) => {
|
test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|||||||
@@ -34,12 +34,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent; /* no WebKit flash on tap */
|
||||||
}
|
}
|
||||||
:global(.tab:disabled) {
|
:global(.tab:disabled) {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
/* The icon square hugs the emoji (just a little padding) so it is the press-highlight
|
/* The icon square hugs the emoji (just a little padding) so the badge can sit on its
|
||||||
target and the badge can sit on its corner. */
|
corner. */
|
||||||
:global(.tab .sq) {
|
:global(.tab .sq) {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -50,12 +51,9 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: background-color 0.12s;
|
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
|
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
||||||
while selected: a filled square with an accent underline, distinct from the
|
while selected: a filled square with an accent underline. A tap itself leaves no
|
||||||
momentary press tint above. */
|
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
|
||||||
:global(.tab.active .sq) {
|
:global(.tab.active .sq) {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
box-shadow: inset 0 -2px 0 var(--accent);
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
|||||||
@@ -297,7 +297,9 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.grid.gridless .cell.dark {
|
.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.filled,
|
||||||
.grid.gridless .cell.pending {
|
.grid.gridless .cell.pending {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +1,11 @@
|
|||||||
// Minimal dependency-free hash router. Hash routing survives a reload and works on
|
// 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
|
// 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 =
|
import { parse, type Route, type RouteName } from './routeparse';
|
||||||
| 'login'
|
|
||||||
| 'lobby'
|
|
||||||
| 'new'
|
|
||||||
| 'game'
|
|
||||||
| 'gameChat'
|
|
||||||
| 'gameCheck'
|
|
||||||
| 'profile'
|
|
||||||
| 'settings'
|
|
||||||
| 'about'
|
|
||||||
| 'friends'
|
|
||||||
| 'stats'
|
|
||||||
| 'notfound';
|
|
||||||
|
|
||||||
export interface Route {
|
export type { Route, RouteName };
|
||||||
name: RouteName;
|
|
||||||
params: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 const router = $state<{ route: Route }>({
|
export const router = $state<{ route: Route }>({
|
||||||
route: parse(typeof location !== 'undefined' ? location.hash : ''),
|
route: parse(typeof location !== 'undefined' ? location.hash : ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user