feat(ui): first-run onboarding coachmarks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
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.
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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':
|
||||
'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}',
|
||||
|
||||
@@ -66,6 +66,16 @@ export const ru: Record<MessageKey, string> = {
|
||||
'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}',
|
||||
|
||||
@@ -133,3 +133,30 @@ export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||
export function savePrefs(p: Prefs): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user