diff --git a/PLAN.md b/PLAN.md
index fd0b767..7ae8a4a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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
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).
+ - **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)
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index cd05eae..2df7f8c 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -36,15 +36,22 @@ Login uses `Screen`.
- **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
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`)
- renders a re-opened game instantly and refreshes it in the background, removing the
- blank-loading flash on lobby ↔ game navigation.
-- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from
- `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the
- Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden,
- the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by
- a background suspend silently reconnects on return to the foreground (the connection
- banner is suppressed while hidden).
+ collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
+ (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
+ and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
+ on lobby ↔ game navigation.
+- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
+ scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
+ which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
+ theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
+ `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
@@ -66,6 +73,12 @@ Login uses `Screen`.
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
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
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
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 6247051..f58157f 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -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
diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte
index f95ff0c..e39b0a6 100644
--- a/ui/src/components/Header.svelte
+++ b/ui/src/components/Header.svelte
@@ -1,14 +1,19 @@
- {#if back}
+ {#if showBack}
diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte
index acd189d..7fa7379 100644
--- a/ui/src/components/Modal.svelte
+++ b/ui/src/components/Modal.svelte
@@ -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 @@
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);
diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte
index 12a2b93..6ebe39b 100644
--- a/ui/src/components/Screen.svelte
+++ b/ui/src/components/Screen.svelte
@@ -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();
- {@render children?.()}
+ {@render children?.()}
{#if tabbar}
{/if}
@@ -50,6 +54,10 @@
.content.scroll {
overflow-y: auto;
}
+ .content.column {
+ display: flex;
+ flex-direction: column;
+ }
.tabbar {
flex: 0 0 auto;
}
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index bc258cc..5d1272e 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -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 @@
]);
-
+
{#snippet menu()}
{/snippet}
@@ -596,7 +603,7 @@
{/if}
{#if checkOpen}
- (checkOpen = false)}>
+ (checkOpen = false)}>
.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;
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index e001ba9..0af18fa 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -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 {
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 {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -232,6 +256,10 @@ export async function bootstrap(): Promise {
// 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);
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index 5440086..1ed3a63 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -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.