Round-6 follow-up: UX polish + client-IP fix
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- Client IP: the compose caddy trusts X-Forwarded-For from private-range upstreams (trusted_proxies private_ranges), so the real client IP survives the host-caddy hop (it was logging the docker caddy hop 172.18.0.x for chat moderation and bucketing the gateway per-IP rate limiter on it). Correct and spoof-safe in both contours (prod has no host caddy); peerIP unit-tested. - Ad banner gated off behind a compile-time SHOW_AD_BANNER=false (the if-branch, the AdBanner import and banner.ts are tree-shaken out of the prod bundle). - Landing: the Telegram entry is just the 64px logo (clickable, no button/text). - TG-fullscreen header: title + menu centred as a pair (hamburger right of the title), pinned to the bottom of the TG nav band. - Edge-swipe back (Screen): a left-edge rightward drag navigates to back (touch/pen only, armed from <=24px; skipped inside Telegram). - Chat soft-keyboard: a bottom-sheet Modal lifted above the keyboard by a visualViewport-driven transform (compositor-only, no page/sheet relayout). iOS-specific, needs on-device tuning; native resize=none awaits Capacitor. - Tests: e2e for the in-game '✓ in friends' item and a board→board tile relocation; codec units for last_activity_unix + OutgoingRequestList. Deferred to the next PR (agreed): #4 enrich the your-turn/game-end push; #5 hide finished games from the lobby.
This commit is contained in:
+12
-14
@@ -74,9 +74,8 @@
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
{#if tgLink}
|
||||
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
||||
{t('landing.playTelegram')}
|
||||
<a class="tg" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -192,21 +191,20 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.play {
|
||||
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link
|
||||
keeps an aria-label for assistive tech (Stage 17). */
|
||||
.tg {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
margin-top: 6px;
|
||||
margin-top: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.play img {
|
||||
.tg img {
|
||||
display: block;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.tg:hover img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.info {
|
||||
background: var(--surface-2);
|
||||
|
||||
@@ -89,11 +89,25 @@
|
||||
transform: rotate(45deg);
|
||||
margin-left: 3px;
|
||||
}
|
||||
/* Telegram fullscreen: its native nav overlays the top of the viewport (height
|
||||
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
|
||||
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
|
||||
corners, leaving the centre clear (Stage 17). */
|
||||
/* Telegram fullscreen: TG's native nav overlays a band of height --tg-content-top at the top
|
||||
of the viewport. Pull our title + menu up into the BOTTOM of that band and centre them as a
|
||||
pair (hamburger right of the title) so they line up with Telegram's own nav controls rather
|
||||
than floating above them (Stage 17). */
|
||||
:global(html.tg-fullscreen) .bar {
|
||||
padding-top: var(--tg-content-top);
|
||||
min-height: var(--tg-content-top);
|
||||
box-sizing: border-box;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
:global(html.tg-fullscreen) .spacer {
|
||||
display: none;
|
||||
}
|
||||
:global(html.tg-fullscreen) h1 {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
:global(html.tg-fullscreen) .end {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
title = '',
|
||||
onclose,
|
||||
overlayKeyboard = false,
|
||||
bottomSheet = false,
|
||||
children,
|
||||
}: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props();
|
||||
}: {
|
||||
title?: string;
|
||||
onclose?: () => void;
|
||||
overlayKeyboard?: boolean;
|
||||
bottomSheet?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
// Track the visual viewport so the backdrop covers only the area above an open
|
||||
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
|
||||
@@ -14,14 +21,21 @@
|
||||
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
|
||||
// overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
|
||||
// simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
|
||||
// bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the
|
||||
// keyboard with a transform (kb), driven by the visual viewport — a compositor-only
|
||||
// move, so neither the page behind nor the sheet relayouts as the keyboard animates
|
||||
// (Stage 17). The backdrop is not resized in this mode (no per-event reflow).
|
||||
let vh = $state(0);
|
||||
let top = $state(0);
|
||||
let kb = $state(0);
|
||||
$effect(() => {
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (!vv || overlayKeyboard) return;
|
||||
const update = () => {
|
||||
vh = vv.height;
|
||||
top = vv.offsetTop;
|
||||
// Soft-keyboard height: the layout viewport minus the visible viewport.
|
||||
kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
@@ -38,11 +52,19 @@
|
||||
<div
|
||||
class="backdrop"
|
||||
class:overlay={overlayKeyboard}
|
||||
style:height={vh ? `${vh}px` : null}
|
||||
style:top={vh ? `${top}px` : null}
|
||||
class:bottom={bottomSheet}
|
||||
style:height={!bottomSheet && vh ? `${vh}px` : null}
|
||||
style:top={!bottomSheet && vh ? `${top}px` : null}
|
||||
onclick={() => onclose?.()}
|
||||
>
|
||||
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
class="sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
style:--kb={bottomSheet ? `${kb}px` : null}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -71,6 +93,20 @@
|
||||
align-items: flex-start;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
/* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the
|
||||
soft keyboard via a transform (--kb) — compositor-only, so the page behind and the
|
||||
sheet itself do not relayout as the keyboard animates (Stage 17). */
|
||||
.backdrop.bottom {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
.backdrop.bottom .sheet {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
transform: translateY(calc(-1 * var(--kb, 0px)));
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Header from './Header.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
|
||||
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
||||
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
|
||||
@@ -27,11 +29,35 @@
|
||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||
column?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
|
||||
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
|
||||
// banner.ts logic — is dead-code-eliminated from the production bundle (Stage 17). Flip to
|
||||
// true to bring it back.
|
||||
const SHOW_AD_BANNER = false;
|
||||
|
||||
// Edge-swipe back (Stage 17): a left-edge rightward drag returns to `back`, the standard
|
||||
// mobile gesture. Armed only from the very left edge (<=24px) so it never competes with the
|
||||
// board's own horizontal gestures; touch/pen only. Skipped inside Telegram, whose native
|
||||
// back button + swipe already cover this (and would otherwise double up).
|
||||
function onEdgeDown(e: PointerEvent): void {
|
||||
if (!back || e.pointerType === 'mouse' || insideTelegram() || e.clientX > 24) return;
|
||||
const x0 = e.clientX;
|
||||
const y0 = e.clientY;
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="screen" onpointerdown={onEdgeDown}>
|
||||
<Header {title} {back} {menu} grow={growNav} />
|
||||
<AdBanner />
|
||||
{#if SHOW_AD_BANNER}<AdBanner />{/if}
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
|
||||
@@ -930,7 +930,7 @@
|
||||
{/if}
|
||||
|
||||
{#if panel === 'chat'}
|
||||
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
|
||||
<Modal title={t('game.chat')} bottomSheet onclose={() => (panel = 'none')}>
|
||||
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeLinkResult,
|
||||
decodeOutgoingList,
|
||||
decodeSession,
|
||||
decodeStateView,
|
||||
decodeStats,
|
||||
@@ -109,6 +110,7 @@ describe('codec', () => {
|
||||
fb.GameView.addMoveCount(b, 4);
|
||||
fb.GameView.addEndReason(b, er);
|
||||
fb.GameView.addSeats(b, seats);
|
||||
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
||||
const game = fb.GameView.endGameView(b);
|
||||
const games = fb.GameList.createGamesVector(b, [game]);
|
||||
fb.GameList.startGameList(b);
|
||||
@@ -120,6 +122,22 @@ describe('codec', () => {
|
||||
expect(gl.games[0].id).toBe('g1');
|
||||
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
||||
expect(gl.games[0].seats[0].score).toBe(13);
|
||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||
});
|
||||
|
||||
it('decodes an OutgoingRequestList of account refs', () => {
|
||||
const b = new Builder(128);
|
||||
const id = b.createString('o-1');
|
||||
const dn = b.createString('Pat');
|
||||
fb.AccountRef.startAccountRef(b);
|
||||
fb.AccountRef.addAccountId(b, id);
|
||||
fb.AccountRef.addDisplayName(b, dn);
|
||||
const ref = fb.AccountRef.endAccountRef(b);
|
||||
const vec = fb.OutgoingRequestList.createRequestsVector(b, [ref]);
|
||||
fb.OutgoingRequestList.startOutgoingRequestList(b);
|
||||
fb.OutgoingRequestList.addRequests(b, vec);
|
||||
b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b));
|
||||
expect(decodeOutgoingList(b.asUint8Array())).toEqual([{ accountId: 'o-1', displayName: 'Pat' }]);
|
||||
});
|
||||
|
||||
it('encodes a TargetRequest', () => {
|
||||
|
||||
Reference in New Issue
Block a user