Files
scrabble-game/ui/src/screens/SettingsHub.svelte
T
Ilia Denisov 1a456c4847
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
feat(offline): transport kill switch + gate the network-requiring UI
Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.

Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
  unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
  the reachability probe — is refused before it leaves the device. Offline
  truly means offline regardless of any UI that slips through. The local
  (device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
  (About) are disabled offline, and the hub falls back to Settings (which holds
  the online/offline toggle); the lobby Stats tab was already disabled. In-game
  social/export are already hidden for a vs_ai game.

Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
2026-07-06 15:46:22 +02:00

72 lines
3.3 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Settings from './Settings.svelte';
import Profile from './Profile.svelte';
import Friends from './Friends.svelte';
import About from './About.svelte';
import { app } from '../lib/app.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
// Friends / About bodies. Tabs switch in place (no navigation), so the back control
// always returns to the lobby. Guests have no social surface, so the Friends tab hides.
type SettingsTab = 'settings' | 'profile' | 'friends' | 'about';
let { initialTab = 'settings' }: { initialTab?: SettingsTab } = $props();
const guest = $derived(app.profile?.isGuest ?? true);
// The active tab is seeded once from the entry route's tab and then owned locally;
// the hub is keyed by route in App.svelte, so initialTab is constant for its lifetime.
// svelte-ignore state_referenced_locally
let tab = $state<SettingsTab>(initialTab);
// A guest who deep-links to the Friends tab falls back to Settings.
$effect(() => {
if (guest && tab === 'friends') tab = 'settings';
});
// Offline mode has no network, so the Profile and Friends surfaces (which read/save over the
// network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip).
// Settings stays reachable: it holds the online/offline toggle.
$effect(() => {
if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings';
});
const titleKey: Record<SettingsTab, MessageKey> = {
settings: 'settings.title',
profile: 'profile.title',
friends: 'friends.title',
about: 'about.title',
};
</script>
<Screen title={t(titleKey[tab])} back="/">
{#if tab === 'settings'}
<Settings />
{:else if tab === 'profile'}
<Profile />
{:else if tab === 'friends'}
<Friends />
{:else}
<About />
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
</button>
<button class="tab" class:active={tab === 'profile'} disabled={offlineMode.active} onclick={() => (tab = 'profile')}>
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
</button>
{#if !guest}
<button class="tab" class:active={tab === 'friends'} disabled={offlineMode.active} onclick={() => (tab = 'friends')}>
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
</button>
{/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="face"><span class="sq" aria-hidden="true">{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span></span>
</button>
</TabBar>
{/snippet}
</Screen>