68ecd881d9
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The deliberate offline mode is now visible: an installed web-PWA user with a confirmed email can switch to offline in Settings, and the header turns blue with an "Offline" chip. (Network gating, the dict preload, the offline lobby + local-game creation, and the service-worker precache follow in later Phase C steps.) - offline.ts / offline.svelte.ts: a sticky, device-scoped reactive offline flag (offlineMode), distinct from connection.svelte's transient reachability signal, with the pure persistence + readiness helpers (offlineReady / missingDicts) unit-tested. - Settings.svelte: an Online / Offline toggle, shown only for an installed web PWA with a confirmed email (the SW-launch + durable-account preconditions). - Header.svelte: a blue-tinted nav (color-mix from the accent, so it tracks light/dark and a Telegram theme override) + an "Offline" chip while offline; the deliberate mode suppresses the transient "Connecting…" indicator. Behaviour-preserving online (the toggle is hidden and offlineMode is false there; e2e 196 green). App entry stays within its size budget (111.6 / 112 KB gzip).
201 lines
7.5 KiB
Svelte
201 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import { navigate } from '../lib/router.svelte';
|
|
import { connection } from '../lib/connection.svelte';
|
|
import { offlineMode } from '../lib/offline.svelte';
|
|
import { t } from '../lib/i18n/index.svelte';
|
|
import { app, openDebug } from '../lib/app.svelte';
|
|
import Spinner from './Spinner.svelte';
|
|
import AdBanner from './AdBanner.svelte';
|
|
|
|
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
|
|
|
// The app always shows its own back chevron when there is a back target — on every platform, in
|
|
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
|
|
// the windowed Mini App (relying on it would lose back navigation there).
|
|
const showBack = $derived(!!back);
|
|
|
|
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
|
|
let titleTaps = 0;
|
|
let lastTitleTap = 0;
|
|
function onTitleTap(): void {
|
|
const now = Date.now();
|
|
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
|
|
lastTitleTap = now;
|
|
if (titleTaps >= 10) {
|
|
titleTaps = 0;
|
|
openDebug();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<header class="nav" class:grow class:offline={offlineMode.active}>
|
|
<div class="bar">
|
|
{#if showBack}
|
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
|
<span class="chev"></span>
|
|
</button>
|
|
{:else}
|
|
<span class="spacer"></span>
|
|
{/if}
|
|
{#if connection.online || offlineMode.active}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<h1 onclick={onTitleTap}>{title}</h1>
|
|
{:else}
|
|
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
|
{/if}
|
|
<!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
|
|
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
|
|
{#if offlineMode.active}
|
|
<span class="chip">{t('settings.offline')}</span>
|
|
{:else}
|
|
<span class="spacer"></span>
|
|
{/if}
|
|
</div>
|
|
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
|
same place on every screen — and in the game (grown nav) the spare height falls below
|
|
it (banner under the title, board pinned to the bottom). It is hidden while a first-run
|
|
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
|
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
|
per this same condition, once onboarding closes. -->
|
|
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
|
<AdBanner
|
|
campaigns={app.profile.banner.campaigns}
|
|
timings={app.profile.banner.timings}
|
|
reduceMotion={app.reduceMotion}
|
|
/>
|
|
{/if}
|
|
</header>
|
|
|
|
<style>
|
|
/* By default the nav bar is minimal and the content fills the screen. In the game
|
|
it grows (class `grow`) to push the board and controls to the bottom. */
|
|
.nav {
|
|
flex: 0 0 auto;
|
|
min-height: 52px;
|
|
background: var(--bg-elev);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
|
|
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
|
|
.nav.offline {
|
|
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
|
|
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
|
}
|
|
.nav.grow {
|
|
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
|
never shrink: on a short viewport the banner keeps its height and the board's own
|
|
scroll (.stage) absorbs the squeeze, instead of the banner and the board splitting it. */
|
|
flex: 1 0 auto;
|
|
}
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--gap);
|
|
padding: 5px var(--pad);
|
|
}
|
|
h1 {
|
|
font-size: 1.05rem;
|
|
margin: 0;
|
|
flex: 1;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
/* The "Connecting…" indicator replaces the title while offline: a spinner + muted label,
|
|
centred like the title so the bar does not shift. */
|
|
h1.connecting {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
}
|
|
.icon,
|
|
.spacer {
|
|
min-width: 40px;
|
|
height: 36px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
|
|
.chip {
|
|
min-width: 40px;
|
|
height: 24px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 8px;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
|
border-radius: 999px;
|
|
white-space: nowrap;
|
|
}
|
|
.back {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0 8px;
|
|
}
|
|
.back:hover {
|
|
background: var(--surface-2);
|
|
}
|
|
/* Android in-app WebViews (Telegram, VK) flash a selection-like box on the two tappable header
|
|
controls — the title (a 10-tap debug target) and the back chevron — on a plain tap. The global
|
|
-webkit-user-select / -webkit-tap-highlight-color on #app is inherited, but those WebViews only
|
|
suppress the artifact when the properties sit DIRECTLY on the tapped element. */
|
|
h1,
|
|
.back {
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
-webkit-touch-callout: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
|
|
.chev {
|
|
width: 11px;
|
|
height: 11px;
|
|
border-left: 2.5px solid currentColor;
|
|
border-bottom: 2.5px solid currentColor;
|
|
transform: rotate(45deg);
|
|
margin-left: 3px;
|
|
}
|
|
/* Telegram fullscreen: TG's native nav occupies the band between the device notch
|
|
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
|
|
is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
|
|
with Telegram's own back/menu controls, which sit in the band's corners. */
|
|
:global(html.tg-fullscreen) .bar {
|
|
/* The bar is sized by its content + padding — in Telegram the title is the only child (the
|
|
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
|
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
|
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
|
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
|
|
Telegram's native controls stays constant if the user scales up the font (the title then
|
|
grows downward and the bar with it). (Owner-tunable: the 8px.) */
|
|
min-height: var(--tg-content-top);
|
|
box-sizing: border-box;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding-top: calc(var(--tg-safe-top) + 8px);
|
|
padding-bottom: 3px;
|
|
}
|
|
:global(html.tg-fullscreen) .spacer {
|
|
display: none;
|
|
}
|
|
:global(html.tg-fullscreen) h1 {
|
|
flex: 0 1 auto;
|
|
}
|
|
</style>
|