6636d7c309
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.
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
// 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 };
|
|
}
|