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:
@@ -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
|
||||
// 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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -48,8 +48,11 @@
|
||||
</div>
|
||||
<!-- 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
|
||||
it (banner under the title, board pinned to the bottom). -->
|
||||
{#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}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
|
||||
Reference in New Issue
Block a user