A coaching step floats a small coach-mark beside a live control. When that control sits inside the scrollable compose dock — the figure picker, the message box, the wager chip, the send button — and the dock has scrolled it out of view, the control still reports a real, non-zero on-screen box. So the mark anchored to a point nobody could see: off-screen on mobile, and on desktop too. The phone tab-switch brings the right tab forward but never scrolls within the dock to the specific control.
The whole fix is one composable. useCoachAnchor already resolves a step's control to a live rect and tracks it; now, on a real step change, it also scrolls that control into view — the first moment the control actually resolves visible. No page wiring changed, no selector logic duplicated. Blast radius is the guided first-run tour alone: the composable runs nowhere else.
The contract, with the new step-entry behavior spelled out.
resolve() walks the step's ordered selectors and stops at the first with a non-zero box — unchanged. The new lines fire only on the first visible resolution of a fresh step: scroll the control into view, then disarm the latch. The order on lines 66–67 is deliberate. A smooth scroll fires its own scroll events, which re-enter resolve(); clearing the latch before the scroll means that re-entry finds it already spent, so the scroll can never fire twice.
resolve(): the first visible hit now also scrolls — once.
The scroll itself is a single call. block: 'center' leaves room above and below for the card whichever side it places on. behavior is the only motion this composable makes, so it reads prefers-reduced-motion fresh at scroll time and snaps instantly when motion is off.
One guarded call; reduced-motion read fresh, not via a reactive ref.
A one-shot latch, armed on entry and flushed on visibility
pendingScroll is a single boolean, armed only when a new selector list arrives — a real step change — in onMounted for the first beat and in the selectors watcher for every beat after. Nothing else arms it, so a reposition (scroll, resize, a mutation) never re-scrolls, and a player who scrolled away mid-beat is left alone.
Armed on a real step change — never on a reposition.
Arming a step whose control is hidden — an inactive phone-tab panel, where the control reports a 0×0 box — does not scroll yet; the latch stays armed. When the tab comes forward and the panel renders, the MutationObserver already watching the tree re-runs resolve(), the control now measures visible, and the deferred scroll fires. Gated on visibility, the scroll waits out the tab-switch instead of racing it. The same mechanism covers desktop, where the control is visible at once and the scroll fires immediately on entry.
Teardown disarms the latch too, so a never-shown beat leaves nothing stale.
Eight new cases pin the behavior. The discriminating one mirrors the exact mobile seam: a control starts hidden (0×0), the latch arms but does not scroll, then its class flips — the attribute mutation the body observer watches — and the scroll flushes exactly once, with no synthetic scroll/resize event involved.
The real #142 seam: a MutationObserver class-toggle flushes the deferred scroll.
The rest pin the edges: scroll on a beat's first entry (block:'center', smooth) and on a beat change; follow the fallback anchor (the wager chip → the ⚡ line); snap instant under reduced motion; do nothing when no control resolves visible; defer then flush on a later reposition; and scroll once, not on every reposition.