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