Stage 17 (contour round 2): Grafana Live/reload, rate-limit, iOS reconnect, hint/plaque/make-move UX

- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link
- deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale)
- rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load)
- iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0)
- hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn
- players plaque: invert so the active seat pops (accent chip, raised) and others recede
- make-move UX: a direct  commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
This commit is contained in:
Ilia Denisov
2026-06-06 12:33:52 +02:00
parent 09fec2b83c
commit c94cd3c3bf
10 changed files with 110 additions and 48 deletions
+31 -22
View File
@@ -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 @@
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if !gameOver && placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
<button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
@@ -545,16 +543,22 @@
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
</TabBar>
{/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;