Stage 17: test-contour verification & defect fixes #19

Merged
developer merged 28 commits from feature/stage-17-contour-verification-fixes into development 2026-06-07 19:20:40 +00:00
9 changed files with 204 additions and 18 deletions
Showing only changes of commit f6bffd1f57 - Show all commits
+15
View File
@@ -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 23, 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
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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>
+12 -2
View File
@@ -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);
+9 -1
View File
@@ -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
View File
@@ -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;
+29 -1
View File
@@ -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);
+82
View File
@@ -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.