// 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 }; }