6a5ce12fab
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Contour review of the VK Bridge group: - #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it reaches the app, so the friend-code link now carries the payload as a query param (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam). (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.) - #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets) and set --tg-safe-* to max(env(), the VK value). - #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the game-land runs flush to the edge. Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch behaviours (Android insets, the ?hash forward) need the live contour.
150 lines
6.1 KiB
Svelte
150 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
import Header from './Header.svelte';
|
|
import { navigate } from '../lib/router.svelte';
|
|
|
|
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
|
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
|
|
// false for screens that own their vertical fit (the game board).
|
|
let {
|
|
title,
|
|
back,
|
|
tabbar,
|
|
children,
|
|
scroll = true,
|
|
growNav = false,
|
|
column = false,
|
|
selfInset = false,
|
|
}: {
|
|
title: string;
|
|
back?: string;
|
|
tabbar?: Snippet;
|
|
children?: Snippet;
|
|
scroll?: boolean;
|
|
growNav?: boolean;
|
|
// selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
|
|
// whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
|
|
// the shell does not add the detached .content padding-bottom below it.
|
|
selfInset?: boolean;
|
|
// column lays the content out as a flex column so a child can own the vertical fit
|
|
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
|
column?: boolean;
|
|
} = $props();
|
|
|
|
// Edge-swipe back: a rightward drag begun in the left band returns to `back`, the standard
|
|
// mobile gesture (instant on release — the route slide plays the animation). Listened at the
|
|
// window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the
|
|
// event) can never swallow it; touch/pen only. The band is the left half of the viewport
|
|
// width (EDGE_FRACTION — widen/narrow there). A hit-test keeps the wide band clear of the
|
|
// gestures it would otherwise hijack: the rack (tile lift/reorder), a draggable pending tile,
|
|
// a zoomed-in board (it pans), and text inputs.
|
|
const EDGE_FRACTION = 0.5;
|
|
const SWIPE_SKIP = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select';
|
|
// A pinch-zoom on the board is two fingers; its second finger lands after the gesture began
|
|
// (the board is not yet .zoomed at the first touch, so the hit-test above can't skip it), and
|
|
// releasing it would otherwise read as a horizontal back-swipe. Track the live pointer count
|
|
// (in the capture phase, so we see every touch even if the board stops propagation) and cancel
|
|
// the swipe the moment a second finger joins. The swipe fires only for a lone finger.
|
|
$effect(() => {
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let active = 0; // pointers currently down
|
|
let tracking = false; // a candidate single-finger edge swipe is in progress
|
|
let pinched = false; // a second finger joined — not a back-swipe
|
|
|
|
function onDown(e: PointerEvent) {
|
|
active++;
|
|
if (active > 1) {
|
|
pinched = true;
|
|
return;
|
|
}
|
|
tracking = false;
|
|
pinched = false;
|
|
if (!back || e.pointerType === 'mouse' || e.clientX > window.innerWidth * EDGE_FRACTION) return;
|
|
if (document.elementFromPoint(e.clientX, e.clientY)?.closest(SWIPE_SKIP)) return;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
tracking = true;
|
|
}
|
|
|
|
function onUp(e: PointerEvent) {
|
|
if (active > 0) active--;
|
|
if (active > 0) return; // wait until every finger has lifted
|
|
if (tracking && !pinched && back) {
|
|
const dx = e.clientX - startX;
|
|
const dy = e.clientY - startY;
|
|
if (dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back);
|
|
}
|
|
tracking = false;
|
|
pinched = false;
|
|
}
|
|
|
|
window.addEventListener('pointerdown', onDown, true);
|
|
window.addEventListener('pointerup', onUp, true);
|
|
window.addEventListener('pointercancel', onUp, true);
|
|
return () => {
|
|
window.removeEventListener('pointerdown', onDown, true);
|
|
window.removeEventListener('pointerup', onUp, true);
|
|
window.removeEventListener('pointercancel', onUp, true);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="screen">
|
|
<Header {title} {back} grow={growNav} />
|
|
<main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
|
|
{#if tabbar}
|
|
<nav class="tabbar">{@render tabbar()}</nav>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.screen {
|
|
display: flex;
|
|
flex-direction: column;
|
|
/* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a
|
|
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
|
scrolling; falls back to the full height where the var is unset. */
|
|
height: var(--vvh, 100%);
|
|
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
|
|
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
|
|
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
|
|
the strip takes the bar's colour rather than the detached content background. */
|
|
padding-left: var(--tg-safe-left, 0px);
|
|
padding-right: var(--tg-safe-right, 0px);
|
|
}
|
|
.content {
|
|
flex: 0 1 auto;
|
|
min-height: 0;
|
|
}
|
|
.content.fill {
|
|
flex: 1 1 auto;
|
|
}
|
|
.content.scroll {
|
|
overflow-y: auto;
|
|
}
|
|
.content.column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
|
|
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
|
|
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
|
|
.content:last-child {
|
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
|
}
|
|
/* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
|
|
game's left-panel controls), so the shell does not add a detached padding strip below it. */
|
|
.content.selfinset:last-child {
|
|
padding-bottom: 0;
|
|
}
|
|
.tabbar {
|
|
flex: 0 0 auto;
|
|
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
|
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
|
|
instead of the content background showing through. */
|
|
background: var(--bg-elev);
|
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
|
}
|
|
</style>
|