Merge pull request 'feat(ui): first-run onboarding coachmarks' (#147) from feature/onboarding-coachmarks into development
This commit was merged in pull request #147.
This commit is contained in:
@@ -25,6 +25,20 @@ theme, and links to the matching per-language Telegram channel; the game itself
|
|||||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
|
### First-run onboarding
|
||||||
|
The first time a player opens the app it walks them through the interface with a light
|
||||||
|
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||||||
|
tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the
|
||||||
|
overlay is gone for good. Two independent series run. The **lobby** series points at the
|
||||||
|
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
|
||||||
|
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
|
||||||
|
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||||||
|
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
|
||||||
|
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
|
||||||
|
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||||||
|
bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The
|
||||||
|
advertising banner hides while the overlay is up and returns when it closes.
|
||||||
|
|
||||||
### Identity & sessions
|
### Identity & sessions
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
A player arrives from a platform (Telegram first), via email login, or as an
|
||||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
|
### Первый запуск: онбординг
|
||||||
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
|
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
|
«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к
|
||||||
|
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||||
|
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||||
|
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||||
|
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||||
|
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||||
|
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||||
|
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||||
|
Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда
|
||||||
|
переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока
|
||||||
|
оверлей показан, и возвращается по закрытию.
|
||||||
|
|
||||||
### Личность и сессии
|
### Личность и сессии
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
|
|||||||
+28
-1
@@ -49,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade
|
|||||||
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
|
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
|
||||||
(unit-tested); `Splash.svelte` is the renderer.
|
(unit-tested); `Splash.svelte` is the renderer.
|
||||||
|
|
||||||
|
## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`)
|
||||||
|
|
||||||
|
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
|
||||||
|
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
|
||||||
|
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
|
||||||
|
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
|
||||||
|
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
|
||||||
|
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
|
||||||
|
a series is marked only after its **last** hint, so an interrupted player replays it from the start.
|
||||||
|
The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer
|
||||||
|
while a modal (`welcomeRedeem` / `staleInvite`) is open.
|
||||||
|
|
||||||
|
Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both
|
||||||
|
orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The
|
||||||
|
bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the
|
||||||
|
geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize /
|
||||||
|
rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text
|
||||||
|
size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on
|
||||||
|
the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists,
|
||||||
|
`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the
|
||||||
|
renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its
|
||||||
|
scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that
|
||||||
|
clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay
|
||||||
|
does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated
|
||||||
|
e2e and the screenshots.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||||||
@@ -253,7 +279,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe
|
|||||||
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
||||||
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
||||||
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
||||||
the profile to show or hide it in place.
|
the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up
|
||||||
|
(`app.coachActive`), reappearing by this same condition once the overlay closes.
|
||||||
|
|
||||||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||||||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The first-run onboarding coachmarks. `?coach` forces the overlay on in the mock build (where it
|
||||||
|
// is otherwise off so it cannot eat the smoke's taps) and bypasses the persisted "seen" flag, so
|
||||||
|
// the series replay deterministically here. A tap anywhere on the overlay advances; after the last
|
||||||
|
// hint the overlay removes itself.
|
||||||
|
|
||||||
|
// Tap the overlay near a corner (clear of the bubble) to advance to the next hint.
|
||||||
|
async function advance(page: Page): Promise<void> {
|
||||||
|
await page.locator('[data-coach-overlay]').click({ position: { x: 5, y: 5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive both series end to end: guest login -> lobby coachmarks (3) -> open the seeded game ->
|
||||||
|
// game coachmarks (5) -> gone. Shared by the portrait and landscape projects/cases.
|
||||||
|
async function runOnboarding(page: Page): Promise<void> {
|
||||||
|
const overlay = page.locator('[data-coach-overlay]');
|
||||||
|
|
||||||
|
await page.goto('/?coach=1');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
|
// Lobby series: settings -> stats -> new game.
|
||||||
|
await expect(overlay).toBeVisible({ timeout: 10000 });
|
||||||
|
await advance(page);
|
||||||
|
await advance(page);
|
||||||
|
await advance(page);
|
||||||
|
await expect(overlay).toBeHidden();
|
||||||
|
|
||||||
|
// The lobby is interactive again: open the seeded active game.
|
||||||
|
await page.getByRole('button', { name: /Ann/ }).click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Game series: header -> pass/exchange -> hints -> shuffle -> rack.
|
||||||
|
await expect(overlay).toBeVisible({ timeout: 10000 });
|
||||||
|
for (let i = 0; i < 5; i++) await advance(page);
|
||||||
|
await expect(overlay).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('onboarding walks the lobby then the first game (portrait)', async ({ page }) => {
|
||||||
|
await runOnboarding(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('landscape', () => {
|
||||||
|
test.use({ viewport: { width: 1100, height: 640 } });
|
||||||
|
|
||||||
|
test('onboarding anchors and advances in the wide layout', async ({ page }) => {
|
||||||
|
await runOnboarding(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import Splash from './components/Splash.svelte';
|
import Splash from './components/Splash.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -120,6 +121,7 @@
|
|||||||
<Toast />
|
<Toast />
|
||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
|
<Coachmark />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// First-run onboarding coachmarks: a light dimmed overlay that points one bubble at a time at a
|
||||||
|
// real UI element (a tab-bar button, the scoreboard, the rack), advancing on a tap anywhere and
|
||||||
|
// removing itself for good after the last hint. Two independent series — the lobby (just
|
||||||
|
// registered) and the first game board — selected by the current route. The geometry lives in
|
||||||
|
// lib/coachmark (unit-tested); this component supplies the live target rectangles, drives the
|
||||||
|
// step machine, and persists completion. Targets are found by their `data-coach` attribute, so
|
||||||
|
// the same code anchors them in portrait and landscape, wherever the layout puts them.
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { app, markOnboardingDone } from '../lib/app.svelte';
|
||||||
|
import { router } from '../lib/router.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { nextVisibleStep, placeBubble, stepsFor, type CoachSeries, type Placement } from '../lib/coachmark';
|
||||||
|
|
||||||
|
// In the mock build the overlay never auto-shows (so the Playwright smoke is not held up by an
|
||||||
|
// overlay that intercepts taps — like the splash). `?coach` forces it on regardless, bypassing
|
||||||
|
// both the mock gate and the "already seen" flag, for screenshots and the dedicated e2e.
|
||||||
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
const forced = typeof location !== 'undefined' && new URLSearchParams(location.search).has('coach');
|
||||||
|
|
||||||
|
// How long to wait for the first target of a series to lay out before giving up (the game board
|
||||||
|
// mounts asynchronously once its state loads).
|
||||||
|
const READY_BUDGET_MS = 8000;
|
||||||
|
|
||||||
|
let activeSeries = $state<CoachSeries | null>(null);
|
||||||
|
let stepIndex = $state(-1);
|
||||||
|
let placement = $state<Placement | null>(null);
|
||||||
|
let bubbleEl = $state<HTMLElement>();
|
||||||
|
// Series already completed in this page session. The persisted flag stops a series from
|
||||||
|
// replaying in real use, but `?coach` deliberately bypasses that flag — so this stops a forced
|
||||||
|
// series from immediately restarting itself the moment it finishes.
|
||||||
|
const sessionDone = new Set<CoachSeries>();
|
||||||
|
|
||||||
|
// Viewport + a generic "layout changed" tick (resize, rotation, scroll, the banner reflow) that
|
||||||
|
// re-runs the measuring effect so a bubble keeps pointing at its target.
|
||||||
|
let vw = $state(0);
|
||||||
|
let vh = $state(0);
|
||||||
|
let tick = $state(0);
|
||||||
|
let pollId = 0;
|
||||||
|
|
||||||
|
function seriesFor(name: string): CoachSeries | null {
|
||||||
|
return name === 'lobby' ? 'lobby' : name === 'game' ? 'game' : null;
|
||||||
|
}
|
||||||
|
function seriesRoute(series: CoachSeries): string {
|
||||||
|
return series === 'lobby' ? 'lobby' : 'game';
|
||||||
|
}
|
||||||
|
function targetEl(target: string): HTMLElement | null {
|
||||||
|
return typeof document === 'undefined' ? null : document.querySelector<HTMLElement>(`[data-coach="${target}"]`);
|
||||||
|
}
|
||||||
|
function present(target: string): boolean {
|
||||||
|
const el = targetEl(target);
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
}
|
||||||
|
function blockedByModal(): boolean {
|
||||||
|
return app.welcomeRedeem || app.staleInvite || app.blocked != null || app.bootError || app.launchError != null;
|
||||||
|
}
|
||||||
|
function eligible(series: CoachSeries): boolean {
|
||||||
|
if (sessionDone.has(series)) return false;
|
||||||
|
const done = series === 'lobby' ? app.onboarding.lobbyDone : app.onboarding.gameDone;
|
||||||
|
if (!(forced || (!isMock && !done))) return false;
|
||||||
|
if (blockedByModal()) return false;
|
||||||
|
// The lobby waits for the loading splash to clear; the game waits for its first target (below).
|
||||||
|
if (series === 'lobby') return app.splashDone;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeStart(series: CoachSeries): void {
|
||||||
|
if (activeSeries || !eligible(series)) return;
|
||||||
|
const i = nextVisibleStep(stepsFor(series), 0, present);
|
||||||
|
if (i < 0) return; // targets not on screen yet — the poll keeps trying
|
||||||
|
activeSeries = series;
|
||||||
|
stepIndex = i;
|
||||||
|
app.coachActive = true;
|
||||||
|
tick++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(): void {
|
||||||
|
if (!activeSeries) return;
|
||||||
|
const i = nextVisibleStep(stepsFor(activeSeries), stepIndex + 1, present);
|
||||||
|
if (i < 0) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stepIndex = i;
|
||||||
|
tick++;
|
||||||
|
}
|
||||||
|
function finish(): void {
|
||||||
|
if (activeSeries) {
|
||||||
|
sessionDone.add(activeSeries);
|
||||||
|
markOnboardingDone(activeSeries);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
function reset(): void {
|
||||||
|
activeSeries = null;
|
||||||
|
stepIndex = -1;
|
||||||
|
placement = null;
|
||||||
|
app.coachActive = false;
|
||||||
|
cancelPoll();
|
||||||
|
}
|
||||||
|
function cancelPoll(): void {
|
||||||
|
if (pollId) {
|
||||||
|
cancelAnimationFrame(pollId);
|
||||||
|
pollId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startPoll(series: CoachSeries): void {
|
||||||
|
cancelPoll();
|
||||||
|
const t0 = performance.now();
|
||||||
|
const loop = (): void => {
|
||||||
|
pollId = 0;
|
||||||
|
if (activeSeries || seriesFor(router.route.name) !== series) return;
|
||||||
|
maybeStart(series);
|
||||||
|
if (activeSeries || performance.now() - t0 > READY_BUDGET_MS) return;
|
||||||
|
pollId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
pollId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestration: pick the series for the route, stop one left behind, and (re)try on every change
|
||||||
|
// to a flag that gates eligibility (splash, modals, blocked, the seen flags). A bounded poll
|
||||||
|
// covers the asynchronous appearance of the first target.
|
||||||
|
$effect(() => {
|
||||||
|
const name = router.route.name;
|
||||||
|
// Touch the reactive eligibility inputs so this re-runs when they change.
|
||||||
|
void app.splashDone;
|
||||||
|
void app.welcomeRedeem;
|
||||||
|
void app.staleInvite;
|
||||||
|
void app.blocked;
|
||||||
|
void app.bootError;
|
||||||
|
void app.launchError;
|
||||||
|
void app.onboarding.lobbyDone;
|
||||||
|
void app.onboarding.gameDone;
|
||||||
|
|
||||||
|
const series = seriesFor(name);
|
||||||
|
if (activeSeries && seriesRoute(activeSeries) !== name) reset();
|
||||||
|
cancelPoll();
|
||||||
|
if (!series) return;
|
||||||
|
maybeStart(series);
|
||||||
|
if (!activeSeries) startPoll(series);
|
||||||
|
});
|
||||||
|
|
||||||
|
// While a series is active, track viewport size and any layout shift so the bubble re-anchors
|
||||||
|
// (rotation, the hidden banner reflow, async fonts). Scroll is observed in the capture phase to
|
||||||
|
// catch inner scrollers. Attached only when active so the always-mounted component adds no
|
||||||
|
// global listeners while idle.
|
||||||
|
$effect(() => {
|
||||||
|
if (!activeSeries || typeof window === 'undefined') return;
|
||||||
|
vw = window.innerWidth;
|
||||||
|
vh = window.innerHeight;
|
||||||
|
const onResize = (): void => {
|
||||||
|
vw = window.innerWidth;
|
||||||
|
vh = window.innerHeight;
|
||||||
|
tick++;
|
||||||
|
};
|
||||||
|
const onLayout = (): void => void tick++;
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('orientationchange', onResize);
|
||||||
|
window.addEventListener('scroll', onLayout, true);
|
||||||
|
const ro = new ResizeObserver(onLayout);
|
||||||
|
ro.observe(document.body);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('orientationchange', onResize);
|
||||||
|
window.removeEventListener('scroll', onLayout, true);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measure the active target and place the bubble, re-measuring each frame until the geometry holds
|
||||||
|
// steady. The route-change slide, the hidden-banner reflow and async fonts all move the target
|
||||||
|
// after the step first renders, so a single measurement can land the bubble at a mid-animation
|
||||||
|
// position; settling fixes that. The bubble is laid out off-screen first so its real size is
|
||||||
|
// known, and a vanished target is skipped.
|
||||||
|
$effect(() => {
|
||||||
|
void vw;
|
||||||
|
void vh;
|
||||||
|
void tick;
|
||||||
|
const series = activeSeries;
|
||||||
|
const i = stepIndex;
|
||||||
|
if (!series || i < 0) {
|
||||||
|
placement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = stepsFor(series)[i].target;
|
||||||
|
let raf = 0;
|
||||||
|
let stableSig = '';
|
||||||
|
let stableMs = 0;
|
||||||
|
let totalMs = 0;
|
||||||
|
const measure = (): void => {
|
||||||
|
const el = targetEl(target);
|
||||||
|
const r = el?.getBoundingClientRect();
|
||||||
|
if (!r || r.width === 0 || r.height === 0) {
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bb = bubbleEl?.getBoundingClientRect();
|
||||||
|
const size = bb && bb.width ? { width: bb.width, height: bb.height } : { width: 280, height: 80 };
|
||||||
|
placement = placeBubble(
|
||||||
|
{ left: r.left, top: r.top, width: r.width, height: r.height },
|
||||||
|
{ width: window.innerWidth, height: window.innerHeight },
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
// Re-measure until the target rect + bubble size hold steady for a short spell (so the bubble
|
||||||
|
// lands at the post-animation position), bounded by a hard cap as a safety net.
|
||||||
|
const sig = `${Math.round(r.left)}:${Math.round(r.top)}:${Math.round(r.width)}:${Math.round(r.height)}:${Math.round(size.width)}`;
|
||||||
|
if (sig === stableSig) stableMs += 16;
|
||||||
|
else {
|
||||||
|
stableSig = sig;
|
||||||
|
stableMs = 0;
|
||||||
|
}
|
||||||
|
totalMs += 16;
|
||||||
|
if (stableMs < 320 && totalMs < 2500) raf = requestAnimationFrame(measure);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(measure);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelPoll();
|
||||||
|
if (activeSeries) app.coachActive = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if activeSeries && stepIndex >= 0}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="coach" data-coach-overlay onclick={advance}>
|
||||||
|
<div
|
||||||
|
class="bubble"
|
||||||
|
class:shown={!!placement}
|
||||||
|
bind:this={bubbleEl}
|
||||||
|
data-side={placement?.side ?? 'bottom'}
|
||||||
|
style="--tail:{placement?.tail ?? 0}px; {placement
|
||||||
|
? `left:${placement.left}px; top:${placement.top}px;`
|
||||||
|
: 'left:-9999px; top:0;'}"
|
||||||
|
>
|
||||||
|
<span class="text">{t(stepsFor(activeSeries)[stepIndex].key)}</span>
|
||||||
|
<span class="tail" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.coach {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the lobby/game and modals (Modal.svelte z 40), below toasts (z 50) so an error toast
|
||||||
|
still shows over it; the DebugPanel (z 10000) stays on top. */
|
||||||
|
z-index: 46;
|
||||||
|
/* Light dimming — the bubble (brand colour) carries the contrast. Tuned via screenshots. */
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
/* The whole layer captures taps: a tap anywhere advances, the UI underneath stays inert. */
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
position: fixed;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: min(72vw, 320px);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
/* Reference: the in-game "N в мешке" counter (0.85rem). */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 130ms ease-out;
|
||||||
|
}
|
||||||
|
.bubble.shown {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* Larger and a touch wider in landscape, where there is room and the portrait size looks tiny. */
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.bubble {
|
||||||
|
max-width: min(46vw, 440px);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding: 11px 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bubble {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The tail: an 18px-wide triangle centred on --tail along the bubble edge that faces the target.
|
||||||
|
`data-side` is where the bubble sits relative to the target, so the tail is on the opposite
|
||||||
|
edge, pointing back at it. */
|
||||||
|
.tail {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.bubble[data-side='bottom'] .tail {
|
||||||
|
top: -9px;
|
||||||
|
left: var(--tail);
|
||||||
|
transform: translateX(-9px);
|
||||||
|
border-left: 9px solid transparent;
|
||||||
|
border-right: 9px solid transparent;
|
||||||
|
border-bottom: 9px solid var(--accent);
|
||||||
|
}
|
||||||
|
.bubble[data-side='top'] .tail {
|
||||||
|
bottom: -9px;
|
||||||
|
left: var(--tail);
|
||||||
|
transform: translateX(-9px);
|
||||||
|
border-left: 9px solid transparent;
|
||||||
|
border-right: 9px solid transparent;
|
||||||
|
border-top: 9px solid var(--accent);
|
||||||
|
}
|
||||||
|
.bubble[data-side='right'] .tail {
|
||||||
|
left: -9px;
|
||||||
|
top: var(--tail);
|
||||||
|
transform: translateY(-9px);
|
||||||
|
border-top: 9px solid transparent;
|
||||||
|
border-bottom: 9px solid transparent;
|
||||||
|
border-right: 9px solid var(--accent);
|
||||||
|
}
|
||||||
|
.bubble[data-side='left'] .tail {
|
||||||
|
right: -9px;
|
||||||
|
top: var(--tail);
|
||||||
|
transform: translateY(-9px);
|
||||||
|
border-top: 9px solid transparent;
|
||||||
|
border-bottom: 9px solid transparent;
|
||||||
|
border-left: 9px solid var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
||||||
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
|
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
|
||||||
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
||||||
import { app, closeDebug } from '../lib/app.svelte';
|
import { app, closeDebug, resetOnboarding } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { shareText } from '../lib/share';
|
import { shareText } from '../lib/share';
|
||||||
import { telegramChromeDiag } from '../lib/telegram';
|
import { telegramChromeDiag } from '../lib/telegram';
|
||||||
@@ -26,12 +26,25 @@
|
|||||||
setTimeout(() => (label = 'Share'), 1500);
|
setTimeout(() => (label = 'Share'), 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the first-run coachmark "seen" flags so the onboarding replays on the next launch — a
|
||||||
|
// manual re-test aid. Stops the click from also closing the panel, like the Share control.
|
||||||
|
let resetLabel = $state('Reset visited');
|
||||||
|
function resetVisited(e: MouseEvent): void {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetOnboarding();
|
||||||
|
resetLabel = 'Cleared — relaunch';
|
||||||
|
setTimeout(() => (resetLabel = 'Reset visited'), 1800);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="overlay" onclick={closeDebug}>
|
<div class="overlay" onclick={closeDebug}>
|
||||||
<button class="share" onclick={share}>{label}</button>
|
<div class="actions">
|
||||||
|
<button class="share" onclick={share}>{label}</button>
|
||||||
|
<button class="reset" onclick={resetVisited}>{resetLabel}</button>
|
||||||
|
</div>
|
||||||
<pre class="body">{report}</pre>
|
<pre class="body">{report}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +62,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.share {
|
.share {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
@@ -58,6 +77,16 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
/* Secondary (outline) styling so it reads as a utility next to the primary Share action. */
|
||||||
|
.reset {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
.body {
|
.body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -48,8 +48,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
<!-- 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
|
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 (banner under the title, board pinned to the bottom). It is hidden while a first-run
|
||||||
{#if app.profile?.banner && app.profile.banner.campaigns.length}
|
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
|
<AdBanner
|
||||||
campaigns={app.profile.banner.campaigns}
|
campaigns={app.profile.banner.campaigns}
|
||||||
timings={app.profile.banner.timings}
|
timings={app.profile.banner.timings}
|
||||||
|
|||||||
@@ -1111,7 +1111,7 @@
|
|||||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
<div class="scoreboard" class:flat={landscape} data-coach="game-header" onclick={landscape ? undefined : toggleHistory}>
|
||||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
@@ -1250,7 +1250,7 @@
|
|||||||
{#snippet rackRow()}
|
{#snippet rackRow()}
|
||||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver}>
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
<Rack
|
<Rack
|
||||||
slots={rackSlots}
|
slots={rackSlots}
|
||||||
@@ -1271,7 +1271,7 @@
|
|||||||
|
|
||||||
{#snippet controlButtons()}
|
{#snippet controlButtons()}
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
||||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
triggerClass="tab"
|
triggerClass="tab"
|
||||||
@@ -1279,7 +1279,7 @@
|
|||||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
onconfirm={doHint}
|
||||||
>
|
>
|
||||||
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</TapConfirm>
|
</TapConfirm>
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
@@ -1288,7 +1288,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
||||||
<span class="sq">🔀</span>
|
<span class="sq" data-coach="game-shuffle">🔀</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -35,7 +35,18 @@ import {
|
|||||||
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
|
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import {
|
||||||
|
clearOnboarding,
|
||||||
|
clearSession,
|
||||||
|
loadOnboarding,
|
||||||
|
loadPrefs,
|
||||||
|
loadSession,
|
||||||
|
type OnboardingState,
|
||||||
|
saveOnboarding,
|
||||||
|
saveSession,
|
||||||
|
savePrefs,
|
||||||
|
} from './session';
|
||||||
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||||
@@ -86,6 +97,12 @@ export const app = $state<{
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
|
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
||||||
|
onboarding: OnboardingState;
|
||||||
|
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
||||||
|
* banner (components/PromoBanner) hides so it does not run above the dimmed onboarding layer,
|
||||||
|
* and reappears (per its own show logic) once onboarding closes. */
|
||||||
|
coachActive: boolean;
|
||||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
|
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
|
||||||
@@ -128,6 +145,8 @@ export const app = $state<{
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
boardLabels: 'beginner',
|
boardLabels: 'beginner',
|
||||||
|
onboarding: { lobbyDone: false, gameDone: false },
|
||||||
|
coachActive: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
chatUnread: {},
|
chatUnread: {},
|
||||||
messageUnread: {},
|
messageUnread: {},
|
||||||
@@ -609,6 +628,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
|
app.onboarding = await loadOnboarding();
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
if (prefs.locale) {
|
if (prefs.locale) {
|
||||||
@@ -904,6 +924,26 @@ export async function logout(): Promise<void> {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markOnboardingDone records that a first-run coachmark series has been completed (its last hint
|
||||||
|
* was shown) and persists the flag, so the series never replays for this client. A series
|
||||||
|
* interrupted before its last hint is left unmarked and replays from the start next launch.
|
||||||
|
*/
|
||||||
|
export function markOnboardingDone(series: CoachSeries): void {
|
||||||
|
if (series === 'lobby') app.onboarding.lobbyDone = true;
|
||||||
|
else app.onboarding.gameDone = true;
|
||||||
|
void saveOnboarding({ ...app.onboarding });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetOnboarding clears both coachmark completion flags (the hidden DebugPanel control), so the
|
||||||
|
* onboarding replays on the next launch — a manual re-test aid.
|
||||||
|
*/
|
||||||
|
export function resetOnboarding(): void {
|
||||||
|
app.onboarding = { lobbyDone: false, gameDone: false };
|
||||||
|
void clearOnboarding();
|
||||||
|
}
|
||||||
|
|
||||||
function persistPrefs(): void {
|
function persistPrefs(): void {
|
||||||
void savePrefs({
|
void savePrefs({
|
||||||
theme: app.theme,
|
theme: app.theme,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
GAME_STEPS,
|
||||||
|
LOBBY_STEPS,
|
||||||
|
nextVisibleStep,
|
||||||
|
placeBubble,
|
||||||
|
stepsFor,
|
||||||
|
type Rect,
|
||||||
|
type Viewport,
|
||||||
|
} from './coachmark';
|
||||||
|
|
||||||
|
describe('stepsFor', () => {
|
||||||
|
it('returns the owner-defined order for each series', () => {
|
||||||
|
expect(stepsFor('lobby')).toBe(LOBBY_STEPS);
|
||||||
|
expect(stepsFor('game')).toBe(GAME_STEPS);
|
||||||
|
expect(LOBBY_STEPS.map((s) => s.target)).toEqual(['lobby-settings', 'lobby-stats', 'lobby-new']);
|
||||||
|
expect(GAME_STEPS.map((s) => s.target)).toEqual(['game-header', 'game-turn', 'game-hints', 'game-shuffle', 'game-rack']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextVisibleStep', () => {
|
||||||
|
const present = () => true;
|
||||||
|
it('returns the start index when its target is present', () => {
|
||||||
|
expect(nextVisibleStep(LOBBY_STEPS, 0, present)).toBe(0);
|
||||||
|
expect(nextVisibleStep(LOBBY_STEPS, 1, present)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips steps whose target is absent', () => {
|
||||||
|
const isPresent = (t: string) => t !== 'game-hints' && t !== 'game-shuffle';
|
||||||
|
// From the hints step (index 2), both hints and shuffle are absent, so it lands on the rack.
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 2, isPresent)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when no further step is visible', () => {
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 5, present)).toBe(-1);
|
||||||
|
expect(nextVisibleStep(GAME_STEPS, 0, () => false)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeBubble', () => {
|
||||||
|
const portrait: Viewport = { width: 390, height: 844 };
|
||||||
|
const bubble = { width: 280, height: 80 };
|
||||||
|
|
||||||
|
it('places the bubble below a target at the top (scoreboard strip)', () => {
|
||||||
|
const target: Rect = { left: 0, top: 50, width: 390, height: 40 };
|
||||||
|
const p = placeBubble(target, portrait, bubble);
|
||||||
|
expect(p.side).toBe('bottom');
|
||||||
|
expect(p.top).toBe(100); // 50 + 40 + gap(10)
|
||||||
|
expect(p.left).toBe(55); // centred under the target, room to spare
|
||||||
|
expect(p.tail).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the bubble above a bottom tab-bar target and clamps it on screen', () => {
|
||||||
|
const target: Rect = { left: 260, top: 790, width: 120, height: 54 };
|
||||||
|
const p = placeBubble(target, portrait, bubble);
|
||||||
|
expect(p.side).toBe('top');
|
||||||
|
expect(p.top).toBe(700); // 790 - gap(10) - height(80)
|
||||||
|
// Centring would overflow the right edge, so the bubble is clamped...
|
||||||
|
expect(p.left).toBe(portrait.width - bubble.width - 8);
|
||||||
|
// ...yet the tail still aims at the target centre.
|
||||||
|
const tailViewportX = p.left + p.tail;
|
||||||
|
expect(Math.abs(tailViewportX - (target.left + target.width / 2))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the bubble to the side of a tall left-panel target (landscape)', () => {
|
||||||
|
const landscape: Viewport = { width: 1100, height: 640 };
|
||||||
|
const wide = { width: 300, height: 120 };
|
||||||
|
const target: Rect = { left: 30, top: 20, width: 60, height: 600 };
|
||||||
|
const p = placeBubble(target, landscape, wide);
|
||||||
|
expect(p.side).toBe('right');
|
||||||
|
expect(p.left).toBe(100); // 30 + 60 + gap(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the bubble and the tail at a corner target', () => {
|
||||||
|
const target: Rect = { left: 0, top: 0, width: 30, height: 30 };
|
||||||
|
const p = placeBubble(target, portrait, { width: 200, height: 60 });
|
||||||
|
expect(p.side).toBe('bottom');
|
||||||
|
expect(p.left).toBe(8); // left margin
|
||||||
|
expect(p.tail).toBe(8); // tail clamped to its minimum off the corner
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a side and keeps the bubble within the viewport when nothing fits', () => {
|
||||||
|
const target: Rect = { left: 0, top: 0, width: 390, height: 844 };
|
||||||
|
const p = placeBubble(target, portrait, { width: 200, height: 80 });
|
||||||
|
expect(p.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(p.left).toBeLessThanOrEqual(portrait.width - 200 - 8);
|
||||||
|
expect(p.top).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(p.top).toBeLessThanOrEqual(portrait.height - 80 - 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Pure logic for the first-run onboarding coachmarks (components/Coachmark.svelte). Kept free of
|
||||||
|
// Svelte and the DOM so it is unit-testable in the node test env: the component supplies the live
|
||||||
|
// target rectangles and renders the bubble from the geometry computed here.
|
||||||
|
|
||||||
|
import type { MessageKey } from './i18n/en';
|
||||||
|
|
||||||
|
/** The two independent onboarding series: the lobby (just registered) and the first game board. */
|
||||||
|
export type CoachSeries = 'lobby' | 'game';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single coachmark step: the `data-coach` attribute of the element the bubble points at and the
|
||||||
|
* i18n key of the hint text. Steps whose target is absent at show time are skipped.
|
||||||
|
*/
|
||||||
|
export interface CoachStep {
|
||||||
|
target: string;
|
||||||
|
key: MessageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lobby series, in the owner-defined order: settings → stats → new game. */
|
||||||
|
export const LOBBY_STEPS: readonly CoachStep[] = [
|
||||||
|
{ target: 'lobby-settings', key: 'onboarding.lobbySettings' },
|
||||||
|
{ target: 'lobby-stats', key: 'onboarding.lobbyStats' },
|
||||||
|
{ target: 'lobby-new', key: 'onboarding.lobbyNew' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The game series, in the owner-defined order: header → pass/exchange → hints → shuffle → rack. */
|
||||||
|
export const GAME_STEPS: readonly CoachStep[] = [
|
||||||
|
{ target: 'game-header', key: 'onboarding.gameHeader' },
|
||||||
|
{ target: 'game-turn', key: 'onboarding.gameTurn' },
|
||||||
|
{ target: 'game-hints', key: 'onboarding.gameHints' },
|
||||||
|
{ target: 'game-shuffle', key: 'onboarding.gameShuffle' },
|
||||||
|
{ target: 'game-rack', key: 'onboarding.gameRack' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Returns the ordered step list for the given series. */
|
||||||
|
export function stepsFor(series: CoachSeries): readonly CoachStep[] {
|
||||||
|
return series === 'lobby' ? LOBBY_STEPS : GAME_STEPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the index of the first step at or after `from` whose target is present, or -1 when no
|
||||||
|
* further step is visible. The component advances through the steps with this, skipping any whose
|
||||||
|
* element is not on screen (e.g. a control hidden in the current game state).
|
||||||
|
*/
|
||||||
|
export function nextVisibleStep(steps: readonly CoachStep[], from: number, isPresent: (target: string) => boolean): number {
|
||||||
|
for (let i = Math.max(0, from); i < steps.length; i++) {
|
||||||
|
if (isPresent(steps[i].target)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal rectangle (a subset of DOMRect) — the target's position in the viewport. */
|
||||||
|
export interface Rect {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewport size the bubble must stay inside. */
|
||||||
|
export interface Viewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The measured bubble box, used to keep it on screen and to aim the tail. */
|
||||||
|
export interface BubbleSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which side of the target the bubble sits on; the tail is drawn on the bubble edge facing it. */
|
||||||
|
export type BubbleSide = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The computed bubble placement. `left`/`top` are the bubble's viewport position; `tail` is the
|
||||||
|
* offset of the tail along the facing edge — an X from the bubble's left for top/bottom sides, a Y
|
||||||
|
* from the bubble's top for left/right sides — so the tail keeps pointing at the target centre even
|
||||||
|
* when the bubble is clamped to the viewport edge.
|
||||||
|
*/
|
||||||
|
export interface Placement {
|
||||||
|
side: BubbleSide;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
tail: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tuning knobs for {@link placeBubble}; all in CSS pixels. */
|
||||||
|
export interface PlaceOpts {
|
||||||
|
/** Gap between the target and the bubble. */
|
||||||
|
gap?: number;
|
||||||
|
/** Minimum distance the bubble keeps from every viewport edge. */
|
||||||
|
margin?: number;
|
||||||
|
/** Half-width of the tail triangle (it cannot sit closer than this to a bubble corner). */
|
||||||
|
tail?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number): number {
|
||||||
|
// When the bubble is larger than the slot (lo > hi) keep the low edge so it stays anchored.
|
||||||
|
return hi < lo ? lo : Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes where to draw the bubble for a target. It prefers the vertical axis (below a target,
|
||||||
|
* else above), then the horizontal axis (right, else left) — picking the first side that fits the
|
||||||
|
* bubble with the gap and edge margin, and otherwise the side with the most room. The bubble is
|
||||||
|
* clamped inside the viewport and the tail is aimed at the target centre (clamped to the bubble
|
||||||
|
* edge), so a target near a corner still gets a correctly pointing tail.
|
||||||
|
*/
|
||||||
|
export function placeBubble(target: Rect, vp: Viewport, bubble: BubbleSize, opts?: PlaceOpts): Placement {
|
||||||
|
const gap = opts?.gap ?? 10;
|
||||||
|
const margin = opts?.margin ?? 8;
|
||||||
|
const tail = opts?.tail ?? 8;
|
||||||
|
|
||||||
|
const tcx = target.left + target.width / 2;
|
||||||
|
const tcy = target.top + target.height / 2;
|
||||||
|
|
||||||
|
const space: Record<BubbleSide, number> = {
|
||||||
|
top: target.top,
|
||||||
|
bottom: vp.height - (target.top + target.height),
|
||||||
|
left: target.left,
|
||||||
|
right: vp.width - (target.left + target.width),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fits = (side: BubbleSide): boolean => {
|
||||||
|
if (side === 'top' || side === 'bottom') return space[side] >= bubble.height + gap + margin;
|
||||||
|
return space[side] >= bubble.width + gap + margin;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bubble below a top target, above a bottom one, then to the side — the layout our targets use.
|
||||||
|
const order: BubbleSide[] = ['bottom', 'top', 'right', 'left'];
|
||||||
|
let side = order.find(fits);
|
||||||
|
if (!side) {
|
||||||
|
side = (Object.keys(space) as BubbleSide[]).reduce((a, b) => (space[a] >= space[b] ? a : b));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'top' || side === 'bottom') {
|
||||||
|
const left = clamp(tcx - bubble.width / 2, margin, vp.width - bubble.width - margin);
|
||||||
|
const rawTop = side === 'top' ? target.top - gap - bubble.height : target.top + target.height + gap;
|
||||||
|
const top = clamp(rawTop, margin, vp.height - bubble.height - margin);
|
||||||
|
const tailX = clamp(tcx - left, tail, bubble.width - tail);
|
||||||
|
return { side, left, top, tail: tailX };
|
||||||
|
}
|
||||||
|
const top = clamp(tcy - bubble.height / 2, margin, vp.height - bubble.height - margin);
|
||||||
|
const rawLeft = side === 'left' ? target.left - gap - bubble.width : target.left + target.width + gap;
|
||||||
|
const left = clamp(rawLeft, margin, vp.width - bubble.width - margin);
|
||||||
|
const tailY = clamp(tcy - top, tail, bubble.height - tail);
|
||||||
|
return { side, left, top, tail: tailY };
|
||||||
|
}
|
||||||
@@ -65,6 +65,17 @@ export const en = {
|
|||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
||||||
|
|
||||||
|
// First-run coachmark hints (components/Coachmark.svelte). The leading emoji in each comment
|
||||||
|
// names the UI element the bubble's tail points at.
|
||||||
|
'onboarding.lobbySettings': 'Friends, game & profile settings, feedback', // ⚙️
|
||||||
|
'onboarding.lobbyStats': 'Stats refresh after each game with an opponent', // ✏️
|
||||||
|
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
|
||||||
|
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
|
||||||
|
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
|
||||||
|
'onboarding.gameHints': 'Available hints', // 🛟
|
||||||
|
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
|
||||||
|
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||||
|
|
||||||
'game.bag': '{n} in the bag',
|
'game.bag': '{n} in the bag',
|
||||||
'game.bagEmpty': 'Bag is empty',
|
'game.bagEmpty': 'Bag is empty',
|
||||||
'game.hints': 'Hints {n}',
|
'game.hints': 'Hints {n}',
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.searchHint':
|
'new.searchHint':
|
||||||
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||||
|
|
||||||
|
// Подсказки первого запуска (components/Coachmark.svelte).
|
||||||
|
'onboarding.lobbySettings': 'Друзья, настройки игры и профиля, обратная связь', // ⚙️
|
||||||
|
'onboarding.lobbyStats': 'Статистика обновляется после каждой игры с соперником', // ✏️
|
||||||
|
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
|
||||||
|
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
|
||||||
|
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
|
||||||
|
'onboarding.gameHints': 'Доступные подсказки', // 🛟
|
||||||
|
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
|
||||||
|
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||||
|
|
||||||
'game.bag': '{n} в мешке',
|
'game.bag': '{n} в мешке',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
|
|||||||
@@ -133,3 +133,30 @@ export async function loadPrefs(): Promise<Partial<Prefs>> {
|
|||||||
export function savePrefs(p: Prefs): Promise<void> {
|
export function savePrefs(p: Prefs): Promise<void> {
|
||||||
return kvSet(PREFS_KEY, p);
|
return kvSet(PREFS_KEY, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = 'onboarding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each first-run coachmark series has been completed. A series is marked done only after
|
||||||
|
* its last hint, so a player interrupted mid-series sees it again from the start next launch.
|
||||||
|
*/
|
||||||
|
export interface OnboardingState {
|
||||||
|
lobbyDone: boolean;
|
||||||
|
gameDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the persisted onboarding completion flags, defaulting to not-yet-seen. */
|
||||||
|
export async function loadOnboarding(): Promise<OnboardingState> {
|
||||||
|
const s = await kvGet<Partial<OnboardingState>>(ONBOARDING_KEY);
|
||||||
|
return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the onboarding completion flags. */
|
||||||
|
export function saveOnboarding(s: OnboardingState): Promise<void> {
|
||||||
|
return kvSet(ONBOARDING_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */
|
||||||
|
export function clearOnboarding(): Promise<void> {
|
||||||
|
return kvDel(ONBOARDING_KEY);
|
||||||
|
}
|
||||||
|
|||||||
@@ -332,13 +332,13 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/stats')}>
|
<button class="tab" onclick={() => navigate('/stats')}>
|
||||||
<span class="sq">✏️</span><span class="lbl">{t('lobby.stats')}</span>
|
<span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" onclick={() => navigate('/settings')}>
|
<button class="tab" onclick={() => navigate('/settings')}>
|
||||||
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
<span class="sq" data-coach="lobby-settings">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
||||||
<span class="lbl">{t('lobby.settings')}</span>
|
<span class="lbl">{t('lobby.settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
|
|||||||
Reference in New Issue
Block a user