From 6636d7c309c9403a6137bb853be579c5203e1955 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 30 Jun 2026 21:48:56 +0200 Subject: [PATCH] feat(ui): first-run onboarding coachmarks A one-time coachmark overlay walks a new player through the lobby and their first game board: a light dimmed layer draws one tail-pointed hint bubble at a time, advancing on a tap anywhere and removing itself for good after the last hint. Two independent series (lobby: settings/stats/new game; game: header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag and marked done only after the last hint, so an interrupted run replays from the start. A deep-link into Settings -> Friends still triggers the lobby series on the first trip back to the lobby. Targets carry a data-coach attribute, so one positioning engine anchors the bubble in both portrait and landscape, re-measuring each frame until the geometry settles (route slide, hidden-banner reflow, fonts). The promo banner hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset visited" control replays the walk-through. Off by default in the mock build so the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e. Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts (unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding story, UI_DESIGN coachmark section. --- docs/FUNCTIONAL.md | 14 ++ docs/FUNCTIONAL_ru.md | 15 ++ docs/UI_DESIGN.md | 29 ++- ui/e2e/onboarding.spec.ts | 48 ++++ ui/src/App.svelte | 2 + ui/src/components/Coachmark.svelte | 330 ++++++++++++++++++++++++++++ ui/src/components/DebugPanel.svelte | 33 ++- ui/src/components/Header.svelte | 7 +- ui/src/game/Game.svelte | 10 +- ui/src/lib/app.svelte.ts | 42 +++- ui/src/lib/coachmark.test.ts | 90 ++++++++ ui/src/lib/coachmark.ts | 149 +++++++++++++ ui/src/lib/i18n/en.ts | 11 + ui/src/lib/i18n/ru.ts | 10 + ui/src/lib/session.ts | 27 +++ ui/src/screens/Lobby.svelte | 6 +- 16 files changed, 809 insertions(+), 14 deletions(-) create mode 100644 ui/e2e/onboarding.spec.ts create mode 100644 ui/src/components/Coachmark.svelte create mode 100644 ui/src/lib/coachmark.test.ts create mode 100644 ui/src/lib/coachmark.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index a554993..7393042 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 (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 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 52c2f66..29225cd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -26,6 +26,21 @@ top-1 подсказку, безлимитную проверку слова с `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. +### Первый запуск: онбординг +При первом открытии приложения игрока один раз проводят по интерфейсу лёгким +**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое +«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к +следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби** +указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии +игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен, +🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только +**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала; +запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший +по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате. +Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда +переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока +оверлей показан, и возвращается по закрытию. + ### Личность и сессии Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 10c523d..387f68d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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` (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 - **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` 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 -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 live outside the components, so navigating between screens (which remounts the view) **continues the diff --git a/ui/e2e/onboarding.spec.ts b/ui/e2e/onboarding.spec.ts new file mode 100644 index 0000000..c91b816 --- /dev/null +++ b/ui/e2e/onboarding.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index bba6053..e337352 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -8,6 +8,7 @@ import Splash from './components/Splash.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; + import Coachmark from './components/Coachmark.svelte'; import DebugPanel from './components/DebugPanel.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; @@ -120,6 +121,7 @@ + {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} diff --git a/ui/src/components/Coachmark.svelte b/ui/src/components/Coachmark.svelte new file mode 100644 index 0000000..1e70172 --- /dev/null +++ b/ui/src/components/Coachmark.svelte @@ -0,0 +1,330 @@ + + +{#if activeSeries && stepIndex >= 0} + + +
+
+ {t(stepsFor(activeSeries)[stepIndex].key)} + +
+
+{/if} + + diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 0513084..8bdf6b0 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,7 +4,7 @@ // 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 // 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 { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; @@ -26,12 +26,25 @@ 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); + }
- +
+ + +
{report}
@@ -49,6 +62,12 @@ gap: 10px; align-items: flex-start; } + .actions { + flex: 0 0 auto; + display: flex; + gap: 10px; + flex-wrap: wrap; + } .share { flex: 0 0 auto; padding: 7px 16px; @@ -58,6 +77,16 @@ border-radius: var(--radius-sm); 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 { margin: 0; width: 100%; diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 7d79b29..67b8e6c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -48,8 +48,11 @@ - {#if app.profile?.banner && app.profile.banner.campaigns.length} + it (banner under the title, board pinned to the bottom). It is hidden while a first-run + 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} -
+
{#if badge}{/if} {#each view.game.seats as s (s.seat)}
@@ -1250,7 +1250,7 @@ {#snippet rackRow()} -
+
- 🔄{t('game.draw')} + 🔄{t('game.draw')} - 🛟{#if hintCount > 0}{hintCount}{/if} + 🛟{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {#if placement.pending.length > 0} @@ -1288,7 +1288,7 @@ {:else} {/if} {/snippet} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2a9de50..980a6d3 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -35,7 +35,18 @@ import { import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; 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 { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -86,6 +97,12 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; 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. */ notifications: number; /** 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', reduceMotion: false, boardLabels: 'beginner', + onboarding: { lobbyDone: false, gameDone: false }, + coachActive: false, notifications: 0, chatUnread: {}, messageUnread: {}, @@ -609,6 +628,7 @@ export async function bootstrap(): Promise { app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; + app.onboarding = await loadOnboarding(); applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { @@ -904,6 +924,26 @@ export async function logout(): Promise { 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 { void savePrefs({ theme: app.theme, diff --git a/ui/src/lib/coachmark.test.ts b/ui/src/lib/coachmark.test.ts new file mode 100644 index 0000000..cf314cf --- /dev/null +++ b/ui/src/lib/coachmark.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/coachmark.ts b/ui/src/lib/coachmark.ts new file mode 100644 index 0000000..e24c775 --- /dev/null +++ b/ui/src/lib/coachmark.ts @@ -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 = { + 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 }; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 07544c3..9e36144 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -65,6 +65,17 @@ export const en = { '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.', + // 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.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 80abbca..0ce61b4 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -66,6 +66,16 @@ export const ru: Record = { '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.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index f4f703d..229aa34 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -133,3 +133,30 @@ export async function loadPrefs(): Promise> { export function savePrefs(p: Prefs): Promise { 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 { + const s = await kvGet>(ONBOARDING_KEY); + return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false }; +} + +/** Persists the onboarding completion flags. */ +export function saveOnboarding(s: OnboardingState): Promise { + return kvSet(ONBOARDING_KEY, s); +} + +/** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */ +export function clearOnboarding(): Promise { + return kvDel(ONBOARDING_KEY); +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index b06ba42..3a5f3a3 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -332,13 +332,13 @@ {#snippet tabbar()} -- 2.52.0