Files
scrabble-game/ui/src/components/Screen.svelte
T
Ilia Denisov 3b20abe0bd
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
feat(ads): banner under the header, continuous across navigation, robust fades
Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
  Screen, so it sits in the same place on every screen. In the game the grown
  nav's spare height now falls below the banner (banner under title, board
  pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
  (lib/bannerEngine) — the scheduler + timer live outside the components, so a
  navigation (which remounts the view) continues the cycle instead of restarting
  it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
  {#if} transition:fade layer, independent of the scroll (the inner track's
  transform), so the two no longer interfere.

Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
2026-06-16 00:35:11 +02:00

123 lines
4.3 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,
}: {
title: string;
back?: string;
tabbar?: Snippet;
children?: Snippet;
scroll?: boolean;
growNav?: 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>{@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%);
}
.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;
}
.tabbar {
flex: 0 0 auto;
}
</style>