feat(pwa): installable web app + landing web entry
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Make the web SPA an installable PWA and surface it to users: - manifest.webmanifest + an install-only service worker + 192/512/maskable icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type registered in the gateway (the distroless image has no /etc/mime.types). - Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa): one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari, hidden elsewhere / once installed / inside a Mini App. Shown under the logged-out login card and at the bottom of Settings. - The landing gains a third entry linking /app/ (the brand tile), with a caption under all three (Telegram / VK / Веб-версия). The service worker is navigation-only (network-first, cached-shell fallback); hashed assets and the Connect stream are untouched. It exists to satisfy Chromium's installability requirement and is the single growth point for a future opt-in offline mode. Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru), gateway README.
This commit is contained in:
+31
-16
@@ -80,20 +80,26 @@
|
||||
<section class="hero">
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
{#if tgLink || vkLink}
|
||||
<div class="entries">
|
||||
{#if tgLink}
|
||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if vkLink}
|
||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
||||
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
|
||||
always available. A caption under each clarifies what it opens. -->
|
||||
<div class="entries">
|
||||
{#if tgLink}
|
||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionTelegram')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if vkLink}
|
||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
||||
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionVK')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a class="entry" href="/app/" aria-label={t('landing.playWeb')}>
|
||||
<img src="favicon.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionWeb')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info">
|
||||
@@ -207,8 +213,8 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
|
||||
each link keeps an aria-label for assistive tech. */
|
||||
/* The platform entries are the bigger logos in a row, each with a short caption below; the
|
||||
link keeps an aria-label for assistive tech. */
|
||||
.entries {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
@@ -217,6 +223,11 @@
|
||||
}
|
||||
.entry {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.entry img {
|
||||
display: block;
|
||||
@@ -225,6 +236,10 @@
|
||||
.entry:hover img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.caption {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.info {
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { installMode, promptInstall } from '../lib/pwa.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// Platform-adaptive: 'oneTap' fires the native Chromium install dialog; 'iosInstructions' opens
|
||||
// the manual Share -> Add-to-Home-Screen guide (iOS Safari has no programmatic install); 'hidden'
|
||||
// renders nothing (already installed, inside a Telegram/VK Mini App, or an unsupported browser).
|
||||
const mode = $derived(installMode());
|
||||
let showIos = $state(false);
|
||||
|
||||
function onClick(): void {
|
||||
// promptInstall must run synchronously from this gesture — the deferred prompt is gated on it.
|
||||
if (mode === 'oneTap') void promptInstall();
|
||||
else if (mode === 'iosInstructions') showIos = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode !== 'hidden'}
|
||||
<button type="button" class="install" onclick={onClick}>
|
||||
<img src="favicon.svg" alt="" width="40" height="40" />
|
||||
<span class="text">
|
||||
<span class="title">{t('install.title')}</span>
|
||||
<span class="subtitle">{t('install.subtitle')}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showIos}
|
||||
<Modal title={t('install.iosTitle')} onclose={() => (showIos = false)}>
|
||||
<ol class="steps">
|
||||
<li>{t('install.iosStep1')}</li>
|
||||
<li>{t('install.iosStep2')}</li>
|
||||
<li>{t('install.iosStep3')}</li>
|
||||
</ol>
|
||||
<button type="button" class="ok" onclick={() => (showIos = false)}>{t('common.ok')}</button>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.install {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.install img {
|
||||
flex: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.steps {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ok {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from './telegram';
|
||||
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
||||
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||
import { registerServiceWorker } from './pwa.svelte';
|
||||
import { haptic } from './haptics';
|
||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||
import { parseStartParam } from './deeplink';
|
||||
@@ -783,6 +784,11 @@ export async function bootstrap(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// On the plain web (both Mini-App branches returned above) register the install-only service
|
||||
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
|
||||
// and skipped in the mock build; see lib/pwa.svelte.
|
||||
registerServiceWorker();
|
||||
|
||||
const saved = await loadSession();
|
||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||
|
||||
@@ -234,6 +234,17 @@ export const en = {
|
||||
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
|
||||
'landing.playTelegram': 'Play in Telegram',
|
||||
'landing.playVK': 'Play on VK',
|
||||
'landing.playWeb': 'Play in your browser',
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Web',
|
||||
|
||||
'install.title': 'Install the app',
|
||||
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
|
||||
'install.iosTitle': 'Add to Home Screen',
|
||||
'install.iosStep1': 'Tap the Share button in the browser toolbar.',
|
||||
'install.iosStep2': 'Choose “Add to Home Screen”.',
|
||||
'install.iosStep3': 'Tap “Add”.',
|
||||
|
||||
'lang.en': 'English',
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
@@ -234,6 +234,17 @@ export const ru: Record<MessageKey, string> = {
|
||||
'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
|
||||
'landing.playTelegram': 'Играть в Telegram',
|
||||
'landing.playVK': 'Играть во ВКонтакте',
|
||||
'landing.playWeb': 'Играть в браузере',
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Веб-версия',
|
||||
|
||||
'install.title': 'Установить приложение',
|
||||
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
|
||||
'install.iosTitle': 'На экран «Домой»',
|
||||
'install.iosStep1': 'Нажмите кнопку «Поделиться» на панели браузера.',
|
||||
'install.iosStep2': 'Выберите «На экран „Домой“».',
|
||||
'install.iosStep3': 'Нажмите «Добавить».',
|
||||
|
||||
'lang.en': 'English',
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed
|
||||
// state, and registers the install-only service worker. The pure platform branching lives in
|
||||
// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts).
|
||||
|
||||
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
|
||||
import { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
|
||||
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
|
||||
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
|
||||
* pwa.ts, so that module stays free of app imports for the node unit tests. */
|
||||
function inMiniApp(): boolean {
|
||||
return insideTelegram() || insideVK();
|
||||
}
|
||||
|
||||
// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types.
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||
appinstalled: Event;
|
||||
}
|
||||
}
|
||||
|
||||
// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription).
|
||||
const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({
|
||||
deferred: null,
|
||||
installed: isStandalone(),
|
||||
});
|
||||
|
||||
/**
|
||||
* installMode is the reactive install-CTA mode for the current platform and any captured prompt.
|
||||
* Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives
|
||||
* (beforeinstallprompt fires shortly after load) or the app is installed.
|
||||
*/
|
||||
export function installMode(): InstallMode {
|
||||
return resolveInstallMode({
|
||||
deferredAvailable: install.deferred !== null,
|
||||
standalone: install.installed,
|
||||
iosSafari: isIosSafari(),
|
||||
inMiniApp: inMiniApp(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* initInstallListeners wires the Chromium install prompt and the installed event. Harmless on
|
||||
* any platform — the events fire only in an installable browser — so it may run unconditionally
|
||||
* at startup, early enough to catch a prompt that fires before the CTA mounts.
|
||||
*/
|
||||
export function initInstallListeners(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Suppress Chromium's mini-infobar; the app presents its own CTA instead.
|
||||
e.preventDefault();
|
||||
install.deferred = e;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => {
|
||||
install.deferred = null;
|
||||
install.installed = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* promptInstall shows the native Chromium install dialog. It MUST be called directly from a user
|
||||
* gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is
|
||||
* single-use, so it is cleared here; a no-op when nothing was captured.
|
||||
*/
|
||||
export async function promptInstall(): Promise<void> {
|
||||
const deferred = install.deferred;
|
||||
if (!deferred) return;
|
||||
install.deferred = null;
|
||||
await deferred.prompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in
|
||||
* the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini
|
||||
* App. Best-effort — a failed registration only means the app is not installable.
|
||||
*/
|
||||
export function registerServiceWorker(): void {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.MODE === 'mock') return;
|
||||
if (inMiniApp()) return;
|
||||
void navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isIosSafari, isStandalone, resolveInstallMode } from './pwa';
|
||||
|
||||
describe('resolveInstallMode', () => {
|
||||
const base = { deferredAvailable: false, standalone: false, iosSafari: false, inMiniApp: false };
|
||||
|
||||
it('hides inside a Mini App even with a captured prompt', () => {
|
||||
expect(resolveInstallMode({ ...base, inMiniApp: true, deferredAvailable: true })).toBe('hidden');
|
||||
});
|
||||
|
||||
it('hides once the app is installed (standalone)', () => {
|
||||
expect(resolveInstallMode({ ...base, standalone: true, deferredAvailable: true })).toBe('hidden');
|
||||
});
|
||||
|
||||
it('offers one-tap when a Chromium prompt was captured', () => {
|
||||
expect(resolveInstallMode({ ...base, deferredAvailable: true })).toBe('oneTap');
|
||||
});
|
||||
|
||||
it('offers iOS instructions on iOS Safari without a prompt', () => {
|
||||
expect(resolveInstallMode({ ...base, iosSafari: true })).toBe('iosInstructions');
|
||||
});
|
||||
|
||||
it('prefers one-tap over iOS instructions when both apply', () => {
|
||||
expect(resolveInstallMode({ ...base, deferredAvailable: true, iosSafari: true })).toBe('oneTap');
|
||||
});
|
||||
|
||||
it('hides on an unsupported browser (no prompt, not iOS)', () => {
|
||||
expect(resolveInstallMode(base)).toBe('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStandalone', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('is true when the display-mode media query matches', () => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: (q: string) => ({ matches: q.includes('standalone') }),
|
||||
navigator: {},
|
||||
});
|
||||
expect(isStandalone()).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for the iOS navigator.standalone flag', () => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
navigator: { standalone: true },
|
||||
});
|
||||
expect(isStandalone()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false in an ordinary browser tab', () => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
navigator: { standalone: false },
|
||||
});
|
||||
expect(isStandalone()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIosSafari', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
const IPHONE_CHROME =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0 Mobile/15E148 Safari/604.1';
|
||||
const ANDROID_CHROME =
|
||||
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
|
||||
const IPAD_SAFARI =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||
|
||||
it('is true for Safari on iPhone', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: IPHONE_SAFARI, maxTouchPoints: 5 });
|
||||
expect(isIosSafari()).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for Safari on iPadOS (Mac UA + touch points)', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 5 });
|
||||
expect(isIosSafari()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a desktop Mac (no touch points)', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 0 });
|
||||
expect(isIosSafari()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for Chrome on iOS (CriOS)', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 });
|
||||
expect(isIosSafari()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for Android Chrome', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 });
|
||||
expect(isIosSafari()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Pure PWA install helpers, kept out of the Svelte layer (and free of app imports) so the
|
||||
// platform branching is unit-testable in the node test env. The reactive runtime (the captured
|
||||
// install prompt, the Mini-App gate, the service-worker registration) lives in pwa.svelte.ts.
|
||||
|
||||
/** InstallMode is how the install call-to-action resolves for the current platform. */
|
||||
export type InstallMode = 'oneTap' | 'iosInstructions' | 'hidden';
|
||||
|
||||
/**
|
||||
* isStandalone reports whether the app is already running as an installed PWA — launched from
|
||||
* the home screen / desktop rather than a browser tab. Both the standard display-mode media
|
||||
* query and the iOS-only navigator.standalone flag are checked. Returns false with no window
|
||||
* (e.g. under the unit tests).
|
||||
*/
|
||||
export function isStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const displayMode = window.matchMedia?.('(display-mode: standalone)').matches ?? false;
|
||||
const iosStandalone =
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
return displayMode || iosStandalone;
|
||||
}
|
||||
|
||||
/**
|
||||
* isIosSafari reports whether the app runs in Safari on iOS / iPadOS — the only browser there
|
||||
* that can add a site to the home screen, and one with no programmatic install (it never fires
|
||||
* beforeinstallprompt). In-app webviews (Telegram, VK) and the iOS Chrome/Firefox/Edge wrappers
|
||||
* are excluded, as is desktop Safari. Used to switch the CTA to manual Share -> Add-to-Home-Screen
|
||||
* instructions.
|
||||
*/
|
||||
export function isIosSafari(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ua = navigator.userAgent;
|
||||
// iPadOS 13+ reports a Mac UA but exposes touch points — catch that as iOS too.
|
||||
const isIos =
|
||||
/iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1);
|
||||
if (!isIos) return false;
|
||||
// Safari only: the iOS Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera (OPiOS) and the
|
||||
// Google-app (GSA) wrappers all carry "Safari" but cannot add to the home screen from our CTA.
|
||||
return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|GSA/.test(ua);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveInstallMode chooses how the install CTA should behave from the platform facts:
|
||||
* `deferredAvailable` — a beforeinstallprompt event was captured (Chromium one-tap); `standalone`
|
||||
* — already installed; `iosSafari` — manual instructions apply; `inMiniApp` — running inside a
|
||||
* Telegram/VK Mini App. The CTA is offered only on the plain web and never once installed.
|
||||
*/
|
||||
export function resolveInstallMode(facts: {
|
||||
deferredAvailable: boolean;
|
||||
standalone: boolean;
|
||||
iosSafari: boolean;
|
||||
inMiniApp: boolean;
|
||||
}): InstallMode {
|
||||
if (facts.inMiniApp || facts.standalone) return 'hidden';
|
||||
if (facts.deferredAvailable) return 'oneTap';
|
||||
if (facts.iosSafari) return 'iosInstructions';
|
||||
return 'hidden';
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
import { initInstallListeners } from './lib/pwa.svelte';
|
||||
|
||||
// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably
|
||||
// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone
|
||||
// landing page (landing.ts) is a normal scrolling document and deliberately omits this class.
|
||||
document.documentElement.classList.add('app-shell');
|
||||
|
||||
// Capture the PWA install prompt as early as possible — before the install CTA mounts. Harmless
|
||||
// where it never fires (Firefox, Safari, the Telegram/VK Mini App webviews).
|
||||
initInstallListeners();
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let code = $state('');
|
||||
@@ -55,6 +56,9 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Web-only install call-to-action, under the login card. Renders nothing unless the app is
|
||||
installable here (see components/InstallApp.svelte / lib/pwa). -->
|
||||
<div class="promo"><InstallApp /></div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -62,8 +66,15 @@
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* Centre the card + install promo as a group, with a gap between them. */
|
||||
align-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
/* Match the card width so the install CTA lines up under it. */
|
||||
.promo {
|
||||
width: min(94vw, 360px);
|
||||
}
|
||||
.card {
|
||||
width: min(94vw, 360px);
|
||||
background: var(--surface);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
@@ -73,6 +74,10 @@
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
|
||||
is installable here — see components/InstallApp.svelte / lib/pwa). -->
|
||||
<InstallApp />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user