Stage 17 (contour round 3): Telegram Mini Apps polish, board scroll, keyboard overlay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 54s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 54s
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll - #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll) - #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware - docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
This commit is contained in:
+11
-1
@@ -2,8 +2,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { router } from './lib/router.svelte';
|
||||
import { navigate, router } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
@@ -19,6 +20,15 @@
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
||||
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||
$effect(() => {
|
||||
if (!insideTelegram()) return;
|
||||
const name = router.route.name;
|
||||
telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
|
||||
});
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
|
||||
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||
$props();
|
||||
|
||||
// Inside Telegram the native header back button (App.svelte) is the back control, so
|
||||
// the app's own chevron is hidden to avoid two back affordances.
|
||||
const showBack = $derived(!!back && !insideTelegram());
|
||||
</script>
|
||||
|
||||
<header class="nav" class:grow>
|
||||
<div class="bar">
|
||||
{#if back}
|
||||
{#if showBack}
|
||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||
<span class="chev"></span>
|
||||
</button>
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
let {
|
||||
title = '',
|
||||
onclose,
|
||||
overlayKeyboard = false,
|
||||
children,
|
||||
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
|
||||
}: { title?: string; onclose?: () => void; overlayKeyboard?: 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
|
||||
// backdrop still centres it behind the keyboard. Sizing the backdrop to
|
||||
// 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).
|
||||
let vh = $state(0);
|
||||
let top = $state(0);
|
||||
$effect(() => {
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (!vv) return;
|
||||
if (!vv || overlayKeyboard) return;
|
||||
const update = () => {
|
||||
vh = vv.height;
|
||||
top = vv.offsetTop;
|
||||
@@ -34,6 +37,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="backdrop"
|
||||
class:overlay={overlayKeyboard}
|
||||
style:height={vh ? `${vh}px` : null}
|
||||
style:top={vh ? `${top}px` : null}
|
||||
onclick={() => onclose?.()}
|
||||
@@ -61,6 +65,12 @@
|
||||
padding: 16px;
|
||||
z-index: 40;
|
||||
}
|
||||
/* Overlay mode: top-anchor the (small) sheet and don't track the keyboard, so the
|
||||
soft keyboard overlays the empty lower area without resizing/relaying out. */
|
||||
.backdrop.overlay {
|
||||
align-items: flex-start;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
children,
|
||||
scroll = true,
|
||||
growNav = false,
|
||||
column = false,
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
@@ -22,13 +23,16 @@
|
||||
children?: Snippet;
|
||||
scroll?: boolean;
|
||||
growNav?: boolean;
|
||||
// column lays the content out as a flex column so a child can own the vertical fit
|
||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||
column?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} {menu} grow={growNav} />
|
||||
<AdBanner />
|
||||
<main class="content" class:scroll class:fill={!growNav}>{@render children?.()}</main>
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
{/if}
|
||||
@@ -50,6 +54,10 @@
|
||||
.content.scroll {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.content.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tabbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
+18
-3
@@ -19,6 +19,7 @@
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||
import {
|
||||
BLANK,
|
||||
newPlacement,
|
||||
@@ -112,6 +113,8 @@
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
// Guard against an accidental swipe-close losing the open game (Telegram).
|
||||
telegramClosingConfirmation(true);
|
||||
// Render instantly from the cache (a game opened before), then refresh in the
|
||||
// background. A cold open shows the loading state until load() resolves.
|
||||
const cached = getCachedGame(id);
|
||||
@@ -185,6 +188,7 @@
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
telegramClosingConfirmation(false);
|
||||
});
|
||||
|
||||
function onCell(row: number, col: number) {
|
||||
@@ -212,12 +216,14 @@
|
||||
return;
|
||||
}
|
||||
placement = place(placement, index, row, col);
|
||||
telegramHaptic('select');
|
||||
recompute();
|
||||
}
|
||||
function chooseBlank(letter: string) {
|
||||
if (!blankPrompt) return;
|
||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||
blankPrompt = null;
|
||||
telegramHaptic('select');
|
||||
recompute();
|
||||
}
|
||||
|
||||
@@ -242,6 +248,7 @@
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||
telegramHaptic('success');
|
||||
zoomed = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -449,7 +456,7 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
|
||||
<Screen title={t('app.title')} back="/" growNav column scroll={false}>
|
||||
{#snippet menu()}
|
||||
<Menu items={menuItems} />
|
||||
{/snippet}
|
||||
@@ -596,7 +603,7 @@
|
||||
{/if}
|
||||
|
||||
{#if checkOpen}
|
||||
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
|
||||
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
|
||||
<div class="check">
|
||||
<input
|
||||
value={checkWord}
|
||||
@@ -635,6 +642,7 @@
|
||||
<style>
|
||||
.scoreboard {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
padding: 8px var(--pad);
|
||||
background: var(--bg-elev);
|
||||
@@ -681,7 +689,12 @@
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* The board is the only part that scrolls vertically when the game does not fit;
|
||||
the score bar, status, rack and tab bar stay put (#9). */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.history {
|
||||
position: absolute;
|
||||
@@ -741,6 +754,7 @@
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px var(--pad) 6px;
|
||||
@@ -762,6 +776,7 @@
|
||||
}
|
||||
.rack-row {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 0 var(--pad) 6px;
|
||||
|
||||
@@ -9,7 +9,16 @@ import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
|
||||
import {
|
||||
insideTelegram,
|
||||
onTelegramPath,
|
||||
telegramColorScheme,
|
||||
telegramDisableVerticalSwipes,
|
||||
telegramHaptic,
|
||||
telegramLaunch,
|
||||
telegramOnEvent,
|
||||
telegramSetChrome,
|
||||
} from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { clearGameCache } from './gamecache';
|
||||
@@ -93,6 +102,7 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
|
||||
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
|
||||
export function handleError(err: unknown): void {
|
||||
telegramHaptic('error');
|
||||
if (err instanceof GatewayError) {
|
||||
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
||||
void logout();
|
||||
@@ -200,6 +210,20 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
||||
app.profile = await gateway.profileGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
|
||||
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
|
||||
*/
|
||||
function syncTelegramChrome(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
telegramSetChrome(
|
||||
cs.getPropertyValue('--bg-elev').trim(),
|
||||
cs.getPropertyValue('--bg').trim(),
|
||||
cs.getPropertyValue('--bg-elev').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const prefs = await loadPrefs();
|
||||
app.theme = prefs.theme ?? 'auto';
|
||||
@@ -232,6 +256,10 @@ export async function bootstrap(): Promise<void> {
|
||||
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
|
||||
// cannot fight it. Falls back to the stored preference when the SDK omits it.
|
||||
applyTheme(telegramColorScheme() ?? app.theme);
|
||||
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
|
||||
// fighting tile drag / board scroll.
|
||||
syncTelegramChrome();
|
||||
telegramDisableVerticalSwipes();
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
await routeStartParam(launch.startParam);
|
||||
|
||||
@@ -13,6 +13,23 @@ interface TelegramWebApp {
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
onEvent?: (event: string, handler: () => void) => void;
|
||||
setHeaderColor?: (color: string) => void;
|
||||
setBackgroundColor?: (color: string) => void;
|
||||
setBottomBarColor?: (color: string) => void;
|
||||
disableVerticalSwipes?: () => void;
|
||||
enableClosingConfirmation?: () => void;
|
||||
disableClosingConfirmation?: () => void;
|
||||
HapticFeedback?: {
|
||||
impactOccurred?: (style: string) => void;
|
||||
notificationOccurred?: (type: string) => void;
|
||||
selectionChanged?: () => void;
|
||||
};
|
||||
BackButton?: {
|
||||
show?: () => void;
|
||||
hide?: () => void;
|
||||
onClick?: (cb: () => void) => void;
|
||||
offClick?: (cb: () => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
@@ -70,6 +87,71 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
||||
return webApp()?.colorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
||||
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
||||
* outside Telegram or on a client predating a given setter.
|
||||
*/
|
||||
export function telegramSetChrome(header: string, background: string, bottom: string): void {
|
||||
const w = webApp();
|
||||
if (header) w?.setHeaderColor?.(header);
|
||||
if (background) w?.setBackgroundColor?.(background);
|
||||
if (bottom) w?.setBottomBarColor?.(bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
|
||||
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
||||
*/
|
||||
export function telegramDisableVerticalSwipes(): void {
|
||||
webApp()?.disableVerticalSwipes?.();
|
||||
}
|
||||
|
||||
/** Haptic is the set of feedbacks the app triggers. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
|
||||
export function telegramHaptic(kind: Haptic): void {
|
||||
const h = webApp()?.HapticFeedback;
|
||||
if (!h) return;
|
||||
if (kind === 'select') h.selectionChanged?.();
|
||||
else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
|
||||
else h.impactOccurred?.(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user
|
||||
* swipes the Mini App closed — enabled during an active game so it is not lost by accident.
|
||||
*/
|
||||
export function telegramClosingConfirmation(on: boolean): void {
|
||||
const w = webApp();
|
||||
if (on) w?.enableClosingConfirmation?.();
|
||||
else w?.disableClosingConfirmation?.();
|
||||
}
|
||||
|
||||
let backHandler: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* telegramBackButton shows or hides Telegram's native header back button, wiring its
|
||||
* click to onClick (replacing any previous handler). The app hides its own back chevron
|
||||
* inside Telegram so only the native control shows.
|
||||
*/
|
||||
export function telegramBackButton(show: boolean, onClick?: () => void): void {
|
||||
const b = webApp()?.BackButton;
|
||||
if (!b) return;
|
||||
if (backHandler) b.offClick?.(backHandler);
|
||||
backHandler = null;
|
||||
if (show) {
|
||||
if (onClick) {
|
||||
backHandler = onClick;
|
||||
b.onClick?.(onClick);
|
||||
}
|
||||
b.show?.();
|
||||
} else {
|
||||
b.hide?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||
|
||||
Reference in New Issue
Block a user