Stage 17 #2: Connecting indicator + auto-retry (no more red toasts) #29

Merged
developer merged 4 commits from feature/connecting-indicator into development 2026-06-09 05:46:43 +00:00
7 changed files with 27 additions and 20 deletions
Showing only changes of commit efa1d0bd22 - Show all commits
+4 -2
View File
@@ -1457,8 +1457,10 @@ provided cert) at the contour caddy; prod VPN; rollback.
still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry), still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry),
so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn` so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn`
hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
on reconnect). Other server-action buttons (chat send, profile save, …) are not yet visually on reconnect). The visual soft-disable spans the server-action buttons across the app: the
disabled but degrade to a safe no-op (the suppressed toast + indicator) — easy to extend. game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ChatMessage } from '../lib/model'; import type { ChatMessage } from '../lib/model';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { connection } from '../lib/connection.svelte';
let { let {
messages, messages,
@@ -55,11 +56,11 @@
bind:value={text} bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()} onkeydown={(e) => e.key === 'Enter' && send()}
/> />
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button> <button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else} {:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. --> <!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span> <span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button> <button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if} {/if}
</div> </div>
</div> </div>
+1 -1
View File
@@ -794,7 +794,7 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}> <Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row"> <div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button> <button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button> <button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
+8 -7
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink'; import { friendCodeParam, shareLink } from '../lib/deeplink';
@@ -95,7 +96,7 @@
inputmode="numeric" inputmode="numeric"
maxlength="6" maxlength="6"
/> />
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#if code}
{@const tg = shareLink(friendCodeParam(code.code))} {@const tg = shareLink(friendCodeParam(code.code))}
@@ -112,7 +113,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button> <button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
{/if} {/if}
</section> </section>
@@ -123,8 +124,8 @@
<div class="item"> <div class="item">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="acts"> <span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button> <button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button> <button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span> </span>
</div> </div>
{/each} {/each}
@@ -138,8 +139,8 @@
<div class="item"> <div class="item">
<span class="who">{f.displayName}</span> <span class="who">{f.displayName}</span>
<span class="acts"> <span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button> <button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button> <button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span> </span>
</div> </div>
{/each} {/each}
@@ -154,7 +155,7 @@
{#each blocked as b (b.accountId)} {#each blocked as b (b.accountId)}
<div class="item"> <div class="item">
<span class="who">{b.displayName}</span> <span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div> </div>
{/each} {/each}
</section> </section>
+2 -1
View File
@@ -4,6 +4,7 @@
import Menu from '../components/Menu.svelte'; import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -191,7 +192,7 @@
{#each group.list as g (g.id)} {#each group.list as g (g.id)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}> <div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished} {#if group.finished}
<button class="del" onclick={() => hide(g.id)} aria-label={t('lobby.hideGame')}></button> <button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if} {/if}
<div class="row"> <div class="row">
<button <button
+3 -2
View File
@@ -3,6 +3,7 @@
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { AccountRef, Variant } from '../lib/model'; import type { AccountRef, Variant } from '../lib/model';
@@ -144,7 +145,7 @@
<p class="subtitle">{t('new.subtitle')}</p> <p class="subtitle">{t('new.subtitle')}</p>
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}> <button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
<span class="vmain"> <span class="vmain">
<span class="vname">{t(v.label)}</span> <span class="vname">{t(v.label)}</span>
{#if VARIANT_FLAG[v.id]} {#if VARIANT_FLAG[v.id]}
@@ -196,7 +197,7 @@
</select> </select>
</label> </label>
</div> </div>
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button> <button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div> </div>
{/if} {/if}
{/if} {/if}
+6 -5
View File
@@ -3,6 +3,7 @@
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte'; import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
@@ -209,7 +210,7 @@
<span>{t('profile.notificationsInAppOnly')}</span> <span>{t('profile.notificationsInAppOnly')}</span>
</label> </label>
<div class="formacts"> <div class="formacts">
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button> <button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div> </div>
</form> </form>
{/if} {/if}
@@ -226,7 +227,7 @@
placeholder={t('login.emailPlaceholder')} placeholder={t('login.emailPlaceholder')}
type="email" type="email"
/> />
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button> <button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
</div> </div>
{:else} {:else}
<div class="addrow"> <div class="addrow">
@@ -237,11 +238,11 @@
inputmode="numeric" inputmode="numeric"
maxlength="6" maxlength="6"
/> />
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button> <button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div> </div>
{/if} {/if}
{#if telegramLinkable} {#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button> <button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{/if} {/if}
</section> </section>
@@ -257,7 +258,7 @@
<p class="warn">{t('profile.mergeIrreversible')}</p> <p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end"> <div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button> <button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button> <button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}