Files
scrabble-game/ui/src/components/Screen.svelte
T
Ilia Denisov 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
fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
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.
2026-06-29 22:15:43 +02:00

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>