diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 67d46a9..42cb76a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -287,6 +287,11 @@ jobs: export SCRABBLE_CONFIG_DIR="$conf" docker compose --ansi never build --progress plain docker compose --ansi never up -d --remove-orphans + # The config-only services bind-mount the reseeded config dir. A plain `up -d` + # leaves them on the previous bind mount (the dir was rm'd + recreated), so a + # changed Caddyfile or Grafana dashboard is ignored โ€” force-recreate them to + # pick up the fresh config. + docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana - name: Probe the gateway through caddy run: | diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 64ee28e..c7e4946 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -18,6 +18,7 @@ Complaints Dictionary Broadcast + Grafana โ†—
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 09cb03d..66c96f3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -209,6 +209,10 @@ services: GF_AUTH_BASIC_ENABLED: "false" GF_USERS_ALLOW_SIGN_UP: "false" GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + # Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits + # caddy's Basic-Auth and re-prompts for the password on every dashboard; the + # dashboards poll and do not need Live. + GF_LIVE_MAX_CONNECTIONS: "0" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 0f442a5..cd89309 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -88,7 +88,11 @@ var ( func DefaultRateLimit() RateLimitConfig { return RateLimitConfig{ PublicPerMinute: 30, PublicBurst: 10, - UserPerMinute: 120, UserBurst: 40, + // Per-user (not per-IP): one user may run several devices, each holding a + // Subscribe stream and reloading state on every live event, so the authenticated + // budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17 + // after multi-device play tripped the old 120/40. + UserPerMinute: 300, UserBurst: 80, AdminPerMinute: 60, AdminBurst: 20, EmailPer10Min: 5, EmailBurst: 2, } diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index cce90f7..82fe149 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -16,16 +16,15 @@ async function openGame(page: Page): Promise { await expect(page.locator('.pane')).toHaveCount(1); } -test('placing a tile and confirming via ๐Ÿ commits the move', async ({ page }) => { +test('placing a tile and confirming via โœ… commits the move', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); - await page.locator('.make').click(); // open the MakeMove popover (short tap) - await page.locator('.pop.go').click(); // "Make move โœ…" + await page.locator('.make').click(); // โœ… commits the move directly (no popover) - // After the commit the placement is cleared: no pending tile, no ๐Ÿ control. + // After the commit the placement is cleared: no pending tile, no โœ… control. await expect(page.locator('[data-cell].pending')).toHaveCount(0); await expect(page.locator('.make')).toBeHidden(); }); diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 112cd49..ace5d86 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -25,6 +25,6 @@ test('guest reaches a board and previews a placement', async ({ page }) => { // The score preview appears where the hints count used to be. await expect(page.locator('.scores')).toContainText(/\d/); - // The contextual MakeMove control (๐Ÿ) appears once a tile is pending. + // The contextual MakeMove control (โœ…) appears once a tile is pending. await expect(page.locator('.make')).toBeVisible(); }); diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 1cad5c0..0afbffe 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -119,4 +119,7 @@ font-size: 1.25rem; line-height: 1; } + .iconbtn:disabled { + opacity: 0.45; + } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index dd82a80..bc258cc 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -126,7 +126,11 @@ $effect(() => { const e = app.lastEvent; if (!e) return; - if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load(); + if (e.kind === 'opponent_moved' && e.gameId === id) { + // Skip the echo of my own move (the backend now notifies the actor too, for the + // player's other devices): this device already reloaded after the submit. + if (e.seat !== view?.seat) void load(); + } else if (e.kind === 'your_turn' && e.gameId === id) void load(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); }); @@ -522,13 +526,7 @@ {#if !gameOver && placement.pending.length > 0} - - {#snippet trigger()}๐Ÿ{/snippet} - {#snippet popover(close)} - - - {/snippet} - + {/if} {:else} @@ -545,16 +543,22 @@ {#snippet trigger()}๐Ÿฅบ{t('game.skip')}{/snippet} {#snippet popover(close)}{/snippet} - + {#snippet trigger()} ๐Ÿ›Ÿ{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} {t('game.hint')} {/snippet} {#snippet popover(close)}{/snippet} - + {#if placement.pending.length > 0} + + {:else} + + {/if} {/if} {/snippet} @@ -641,15 +645,18 @@ text-align: center; padding: 5px 4px; border-radius: var(--radius-sm); - background: var(--surface-2); - /* inactive seats read as "sunk in" */ - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22); + /* inactive seats recede: they blend into the bar, slightly sunk */ + background: transparent; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18); + } + .seat .nm { + color: var(--text-muted); } .seat.turn { - /* the active seat is "raised": lifted clear of the others with side shadows */ - background: var(--bg-elev); + /* the active seat pops: a raised, accented chip lifted clear of the bar */ + background: var(--surface-2); box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.16), + 0 2px 6px rgba(0, 0, 0, 0.3), -3px 0 6px -2px rgba(0, 0, 0, 0.26), 3px 0 6px -2px rgba(0, 0, 0, 0.26); position: relative; @@ -767,16 +774,18 @@ flex: 1; min-width: 0; } - .flag { - font-size: 1.6rem; - } - :global(.make) { + .make { min-width: 56px; background: var(--accent); color: var(--accent-text); + border: none; border-radius: var(--radius-sm); display: grid; place-items: center; + font-size: 1.6rem; + } + .make:disabled { + opacity: 0.55; } .pop { padding: 9px 14px; diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 926b921..848bbbd 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -9,7 +9,7 @@ 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 } from './telegram'; +import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearGameCache } from './gamecache'; @@ -52,11 +52,38 @@ let streamAlive = false; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; -/** documentHidden reports whether the app is currently backgrounded. */ +// Background/foreground tracking, to silence the reconnect banner during a normal app +// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return. +let backgrounded = false; +let foregroundedAt = 0; +const reconnectGraceMs = 4000; + +/** documentHidden reports whether the page is currently hidden. */ function documentHidden(): boolean { return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } +/** + * bannerSuppressed reports whether the connection banner should stay hidden: while + * backgrounded, and for a short grace after returning to the foreground โ€” a connection + * dropped while suspended surfaces its error on resume, before the silent reconnect lands. + */ +function bannerSuppressed(): boolean { + return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs; +} + +function goBackground(): void { + backgrounded = true; +} + +function goForeground(): void { + backgrounded = false; + foregroundedAt = Date.now(); + if (!app.session) return; + if (!streamAlive) openStream(); // silently re-establish a stream dropped while away + void refreshNotifications(); +} + export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text }; if (toastTimer) clearTimeout(toastTimer); @@ -96,14 +123,10 @@ function openStream(): void { }, () => { streamAlive = false; - // A background suspend (iOS / Telegram) drops the single-shot stream. Don't - // alarm the user with the connection banner while hidden โ€” reconnect silently - // on return (the visibilitychange handler). Show the banner only on a failure - // seen in the foreground, and retry it. - if (!documentHidden()) { - showToast(t('error.unavailable'), 'error'); - scheduleReconnect(); - } + // A background suspend drops the single-shot stream. Keep the banner hidden while + // backgrounded or just-resumed (bannerSuppressed); always schedule a retry. + if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error'); + scheduleReconnect(); }, ); } @@ -114,7 +137,7 @@ function scheduleReconnect(): void { if (reconnectTimer || !app.session) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; - if (app.session && !streamAlive && !documentHidden()) openStream(); + if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } @@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void { persistPrefs(); } -// On return to the foreground: silently re-establish a stream dropped while the app -// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any -// push 'notify' missed while hidden (poll + push, see ยง10). +// Background/foreground lifecycle: silence the reconnect banner during a suspend and +// reconnect quietly on return (and refresh the lobby badge for any push missed while +// hidden, ยง10). Several signals cover the platforms: the page Visibility API, the +// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0). if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && app.session) { - if (!streamAlive) openStream(); - void refreshNotifications(); - } - }); + document.addEventListener('visibilitychange', () => + document.visibilityState === 'visible' ? goForeground() : goBackground(), + ); } +if (typeof window !== 'undefined') { + window.addEventListener('pageshow', goForeground); + window.addEventListener('pagehide', goBackground); +} +telegramOnEvent('activated', goForeground); +telegramOnEvent('deactivated', goBackground); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index da5455d..5440086 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -12,6 +12,7 @@ interface TelegramWebApp { colorScheme?: 'light' | 'dark'; ready?: () => void; expand?: () => void; + onEvent?: (event: string, handler: () => void) => void; } function webApp(): TelegramWebApp | undefined { @@ -49,6 +50,15 @@ export function telegramLaunch(): TelegramLaunch { return { initData: w.initData, startParam, theme: w.themeParams }; } +/** + * telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' / + * 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client + * that predates the event, so callers can register defensively. + */ +export function telegramOnEvent(event: string, handler: () => void): void { + webApp()?.onEvent?.(event, handler); +} + /** * telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'), * or undefined outside Telegram. Inside the Mini App this โ€” not the OS