Stage 17: test-contour verification & defect fixes #19
@@ -1254,6 +1254,21 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
|||||||
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
|
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
|
||||||
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
|
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
|
||||||
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
|
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
|
||||||
|
- **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana
|
||||||
|
double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
|
||||||
|
disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
|
||||||
|
move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
|
||||||
|
config-only services on a stale bind mount — the deploy now **force-recreates**
|
||||||
|
caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
|
||||||
|
300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
|
||||||
|
reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
|
||||||
|
+ Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
|
||||||
|
chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
|
||||||
|
**BackButton**, **HapticFeedback**, **closing confirmation** in a game,
|
||||||
|
**disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
|
||||||
|
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
|
||||||
|
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
|
||||||
|
**keyboard-overlay** check-word dialog (#10).
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -36,15 +36,22 @@ Login uses `Screen`.
|
|||||||
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
||||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||||
from the left (back). Transitions are local (so they do not play on first load) and
|
from the left (back). Transitions are local (so they do not play on first load) and
|
||||||
collapse to nothing under reduce-motion. A per-game in-memory cache (`lib/gamecache.ts`)
|
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||||||
renders a re-opened game instantly and refreshes it in the background, removing the
|
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||||||
blank-loading flash on lobby ↔ game navigation.
|
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||||||
- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from
|
on lobby ↔ game navigation.
|
||||||
`Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the
|
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
|
||||||
Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden,
|
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||||||
the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by
|
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||||
a background suspend silently reconnects on return to the foreground (the connection
|
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||||
banner is suppressed while hidden).
|
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
||||||
|
native header **BackButton** drives back-navigation (the app's chevron is hidden in
|
||||||
|
Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing
|
||||||
|
confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise)
|
||||||
|
are disabled so they don't fight tile drag or the board scroll; and a live stream dropped
|
||||||
|
by a background suspend reconnects silently on return — the connection banner is
|
||||||
|
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||||||
|
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
||||||
|
|
||||||
## Tiles & board
|
## Tiles & board
|
||||||
|
|
||||||
@@ -66,6 +73,12 @@ Login uses `Screen`.
|
|||||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||||
jitter. A move's row lists every word it formed (the main word first).
|
jitter. A move's row lists every word it formed (the main word first).
|
||||||
|
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
|
||||||
|
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||||
|
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||||
|
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||||
|
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
||||||
|
keyboard-aware (they size to the area above the keyboard).
|
||||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
||||||
last completed word gets a dark tile background — static while it is the opponent's
|
last completed word gets a dark tile background — static while it is the opponent's
|
||||||
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
||||||
|
|||||||
+11
-1
@@ -2,8 +2,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
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 { t } from './lib/i18n/index.svelte';
|
||||||
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -19,6 +20,15 @@
|
|||||||
void bootstrap();
|
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
|
// 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
|
// 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
|
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { navigate } from '../lib/router.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 } =
|
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||||
$props();
|
$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>
|
</script>
|
||||||
|
|
||||||
<header class="nav" class:grow>
|
<header class="nav" class:grow>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
{#if back}
|
{#if showBack}
|
||||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
<span class="chev"></span>
|
<span class="chev"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,18 +4,21 @@
|
|||||||
let {
|
let {
|
||||||
title = '',
|
title = '',
|
||||||
onclose,
|
onclose,
|
||||||
|
overlayKeyboard = false,
|
||||||
children,
|
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
|
// 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
|
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
|
||||||
// backdrop still centres it behind the keyboard. Sizing the backdrop to
|
// backdrop still centres it behind the keyboard. Sizing the backdrop to
|
||||||
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
|
// 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 vh = $state(0);
|
||||||
let top = $state(0);
|
let top = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
if (!vv) return;
|
if (!vv || overlayKeyboard) return;
|
||||||
const update = () => {
|
const update = () => {
|
||||||
vh = vv.height;
|
vh = vv.height;
|
||||||
top = vv.offsetTop;
|
top = vv.offsetTop;
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="backdrop"
|
class="backdrop"
|
||||||
|
class:overlay={overlayKeyboard}
|
||||||
style:height={vh ? `${vh}px` : null}
|
style:height={vh ? `${vh}px` : null}
|
||||||
style:top={vh ? `${top}px` : null}
|
style:top={vh ? `${top}px` : null}
|
||||||
onclick={() => onclose?.()}
|
onclick={() => onclose?.()}
|
||||||
@@ -61,6 +65,12 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
z-index: 40;
|
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 {
|
.sheet {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
children,
|
children,
|
||||||
scroll = true,
|
scroll = true,
|
||||||
growNav = false,
|
growNav = false,
|
||||||
|
column = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
back?: string;
|
back?: string;
|
||||||
@@ -22,13 +23,16 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
growNav?: 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();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<Header {title} {back} {menu} grow={growNav} />
|
<Header {title} {back} {menu} grow={growNav} />
|
||||||
<AdBanner />
|
<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}
|
{#if tabbar}
|
||||||
<nav class="tabbar">{@render tabbar()}</nav>
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -50,6 +54,10 @@
|
|||||||
.content.scroll {
|
.content.scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.content.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.tabbar {
|
.tabbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -19,6 +19,7 @@
|
|||||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
||||||
|
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
@@ -112,6 +113,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMount(() => {
|
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
|
// Render instantly from the cache (a game opened before), then refresh in the
|
||||||
// background. A cold open shows the loading state until load() resolves.
|
// background. A cold open shows the loading state until load() resolves.
|
||||||
const cached = getCachedGame(id);
|
const cached = getCachedGame(id);
|
||||||
@@ -185,6 +188,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
telegramClosingConfirmation(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onCell(row: number, col: number) {
|
function onCell(row: number, col: number) {
|
||||||
@@ -212,12 +216,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
placement = place(placement, index, row, col);
|
placement = place(placement, index, row, col);
|
||||||
|
telegramHaptic('select');
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
function chooseBlank(letter: string) {
|
function chooseBlank(letter: string) {
|
||||||
if (!blankPrompt) return;
|
if (!blankPrompt) return;
|
||||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||||
blankPrompt = null;
|
blankPrompt = null;
|
||||||
|
telegramHaptic('select');
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +248,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||||
|
telegramHaptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -449,7 +456,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
|
<Screen title={t('app.title')} back="/" growNav column scroll={false}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<Menu items={menuItems} />
|
<Menu items={menuItems} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -596,7 +603,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if checkOpen}
|
{#if checkOpen}
|
||||||
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
|
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
|
||||||
<div class="check">
|
<div class="check">
|
||||||
<input
|
<input
|
||||||
value={checkWord}
|
value={checkWord}
|
||||||
@@ -635,6 +642,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.scoreboard {
|
.scoreboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px var(--pad);
|
padding: 8px var(--pad);
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
@@ -681,7 +689,12 @@
|
|||||||
}
|
}
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
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 {
|
.history {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -741,6 +754,7 @@
|
|||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 2px var(--pad) 6px;
|
padding: 2px var(--pad) 6px;
|
||||||
@@ -762,6 +776,7 @@
|
|||||||
}
|
}
|
||||||
.rack-row {
|
.rack-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 0 var(--pad) 6px;
|
padding: 0 var(--pad) 6px;
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ import { GatewayError } from './client';
|
|||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
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 { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
import { clearGameCache } from './gamecache';
|
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. */
|
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
|
||||||
export function handleError(err: unknown): void {
|
export function handleError(err: unknown): void {
|
||||||
|
telegramHaptic('error');
|
||||||
if (err instanceof GatewayError) {
|
if (err instanceof GatewayError) {
|
||||||
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
||||||
void logout();
|
void logout();
|
||||||
@@ -200,6 +210,20 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|||||||
app.profile = await gateway.profileGet();
|
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> {
|
export async function bootstrap(): Promise<void> {
|
||||||
const prefs = await loadPrefs();
|
const prefs = await loadPrefs();
|
||||||
app.theme = prefs.theme ?? 'auto';
|
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)
|
// 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.
|
// cannot fight it. Falls back to the stored preference when the SDK omits it.
|
||||||
applyTheme(telegramColorScheme() ?? app.theme);
|
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 {
|
try {
|
||||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||||
await routeStartParam(launch.startParam);
|
await routeStartParam(launch.startParam);
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ interface TelegramWebApp {
|
|||||||
ready?: () => void;
|
ready?: () => void;
|
||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
onEvent?: (event: string, handler: () => void) => 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 {
|
function webApp(): TelegramWebApp | undefined {
|
||||||
@@ -70,6 +87,71 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
|||||||
return webApp()?.colorScheme;
|
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
|
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||||
|
|||||||
Reference in New Issue
Block a user