What changed, and why

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.

composables/useCoachAnchor.ts · 178 lines
composables/useCoachAnchor.ts178 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges — and,
21 * on a real step change, scroll that target into view on entry (see below). The
22 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
23 * to an always-present one (the general ⚡ line).
24 *
25 * Returns present=false when no selector resolves to a *visible* element —
26 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
27 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
28 * heavy observers run only while a selector list is set (i.e. while a tour beat
29 * is on screen), so there's no idle cost on the board.
30 *
31 * On a real step change (a new selector list) the freshly-resolved control is
32 * scrolled into view the first moment it resolves visible — so the mark never
33 * points at a control rendered but scrolled out of the compose dock, the one
34 * thing a phone tab-switch alone can't fix (issue #142). Gated on visibility, it
35 * naturally waits out that tab-switch instead of racing it.
36 */
⋯ 142 lines hidden (lines 37–178)
37export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
38 const rect = ref<AnchorRect | null>(null)
39 const present = ref(false)
40 
41 function measure(el: Element): AnchorRect | null {
42 // A zero-area rect is how a hidden control reads — display:none (an inactive
43 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
44 // so the caller falls back instead of anchoring to a point nobody can see.
45 const r = el.getBoundingClientRect()
46 if (r.width === 0 && r.height === 0) return null
47 return { top: r.top, left: r.left, width: r.width, height: r.height }
48 }
49 
50 function resolve() {
51 const list = selectors.value
52 if (!list || !list.length || typeof document === 'undefined') {
53 rect.value = null
54 present.value = false
55 return
56 }
57 for (const sel of list) {
58 const el = document.querySelector(sel)
59 if (!el) continue
60 const measured = measure(el)
61 if (measured) {
62 rect.value = measured
63 present.value = true
64 // First visible resolution of a new beat: bring the control into view.
65 if (pendingScroll) {
66 pendingScroll = false
67 scrollAnchorIntoView(el)
68 }
69 return
70 }
71 }
72 rect.value = null
73 present.value = false
74 }
75 
76 // A real step change asks for its anchor to be scrolled into view once it
77 // resolves visible — see resolve(). One-shot per beat: armed on entry below,
78 // disarmed by the first scroll, so repositions within the beat never re-scroll.
79 let pendingScroll = false
80 
81 // Bring the beat's freshly-resolved control into view before the mark places — a
82 // control rendered but scrolled out of the compose dock (mobile AND desktop)
83 // otherwise leaves the mark pointing at an off-screen spot (issue #142). This is
84 // the lone bit of MOTION here, so — unlike the event-driven tracking below — it
85 // honors prefers-reduced-motion, snapping instantly when motion is off. block:
86 // 'center' keeps room above and below for the card whichever way it places.
87 function scrollAnchorIntoView(el: Element) {
88 if (typeof el.scrollIntoView !== 'function') return
89 // Read fresh here, not via usePrefersReducedMotion — a one-shot imperative
90 // read at scroll time, not a reactive binding (mirrors stores/audio.ts).
91 const reduce =
92 typeof window !== 'undefined' &&
93 typeof window.matchMedia === 'function' &&
94 window.matchMedia('(prefers-reduced-motion: reduce)').matches
95 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduce ? 'instant' : 'smooth' })
96 }
97 
98 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
99 // per frame. Event-driven layout sync, NOT an animation loop (nothing
100 // self-re-arms), so it needs no reduced-motion guard.
101 let frame = 0
102 function schedule() {
103 if (frame || typeof requestAnimationFrame === 'undefined') {
104 if (typeof requestAnimationFrame === 'undefined') resolve()
105 return
106 }
107 frame = requestAnimationFrame(() => {
108 frame = 0
109 resolve()
110 })
111 }
112 
113 let tracking = false
114 let ro: ResizeObserver | null = null
115 let mo: MutationObserver | null = null
116 
117 function start() {
118 if (tracking || typeof window === 'undefined') return
119 tracking = true
120 resolve()
121 window.addEventListener('scroll', schedule, { capture: true, passive: true })
122 window.addEventListener('resize', schedule, { passive: true })
123 if (typeof ResizeObserver !== 'undefined') {
124 ro = new ResizeObserver(schedule)
125 ro.observe(document.documentElement)
126 }
127 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
128 // without a scroll or resize — watch the tree so the anchor re-resolves.
129 if (typeof MutationObserver !== 'undefined') {
130 mo = new MutationObserver(schedule)
131 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
132 }
133 }
134 
135 function stop() {
136 tracking = false
137 if (frame) {
138 cancelAnimationFrame(frame)
139 frame = 0
140 }
141 if (typeof window !== 'undefined') {
142 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
143 window.removeEventListener('resize', schedule)
144 }
145 ro?.disconnect()
146 ro = null
147 mo?.disconnect()
148 mo = null
149 // Disarm here too, so the one-shot is local to this lifecycle: a beat armed
150 // but never resolved-visible (its panel never came forward) can't leave a
151 // stale latch that a later resolve() would honor on a torn-down beat.
152 pendingScroll = false
153 rect.value = null
154 present.value = false
155 }
156 
157 onMounted(() => {
158 if (selectors.value?.length) {
159 pendingScroll = true
160 start()
161 }
162 })
163 
164 watch(selectors, (list) => {
165 if (!list || !list.length) {
166 stop()
167 return
168 }
169 // A new beat's anchors — scroll its control into view once resolved (resolve()).
170 pendingScroll = true
171 if (!tracking) start()
172 else resolve()
173 })
174 
175 onBeforeUnmount(stop)
176 
177 return { rect, present }

The scroll, where resolution already happens

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.

composables/useCoachAnchor.ts · 178 lines
composables/useCoachAnchor.ts178 lines · TypeScript
⋯ 49 lines hidden (lines 1–49)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges — and,
21 * on a real step change, scroll that target into view on entry (see below). The
22 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
23 * to an always-present one (the general ⚡ line).
24 *
25 * Returns present=false when no selector resolves to a *visible* element —
26 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
27 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
28 * heavy observers run only while a selector list is set (i.e. while a tour beat
29 * is on screen), so there's no idle cost on the board.
30 *
31 * On a real step change (a new selector list) the freshly-resolved control is
32 * scrolled into view the first moment it resolves visible — so the mark never
33 * points at a control rendered but scrolled out of the compose dock, the one
34 * thing a phone tab-switch alone can't fix (issue #142). Gated on visibility, it
35 * naturally waits out that tab-switch instead of racing it.
36 */
37export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
38 const rect = ref<AnchorRect | null>(null)
39 const present = ref(false)
40 
41 function measure(el: Element): AnchorRect | null {
42 // A zero-area rect is how a hidden control reads — display:none (an inactive
43 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
44 // so the caller falls back instead of anchoring to a point nobody can see.
45 const r = el.getBoundingClientRect()
46 if (r.width === 0 && r.height === 0) return null
47 return { top: r.top, left: r.left, width: r.width, height: r.height }
48 }
49 
50 function resolve() {
51 const list = selectors.value
52 if (!list || !list.length || typeof document === 'undefined') {
53 rect.value = null
54 present.value = false
55 return
56 }
57 for (const sel of list) {
58 const el = document.querySelector(sel)
59 if (!el) continue
60 const measured = measure(el)
61 if (measured) {
62 rect.value = measured
63 present.value = true
64 // First visible resolution of a new beat: bring the control into view.
65 if (pendingScroll) {
66 pendingScroll = false
67 scrollAnchorIntoView(el)
68 }
69 return
70 }
71 }
72 rect.value = null
73 present.value = false
74 }
⋯ 104 lines hidden (lines 75–178)
75 
76 // A real step change asks for its anchor to be scrolled into view once it
77 // resolves visible — see resolve(). One-shot per beat: armed on entry below,
78 // disarmed by the first scroll, so repositions within the beat never re-scroll.
79 let pendingScroll = false
80 
81 // Bring the beat's freshly-resolved control into view before the mark places — a
82 // control rendered but scrolled out of the compose dock (mobile AND desktop)
83 // otherwise leaves the mark pointing at an off-screen spot (issue #142). This is
84 // the lone bit of MOTION here, so — unlike the event-driven tracking below — it
85 // honors prefers-reduced-motion, snapping instantly when motion is off. block:
86 // 'center' keeps room above and below for the card whichever way it places.
87 function scrollAnchorIntoView(el: Element) {
88 if (typeof el.scrollIntoView !== 'function') return
89 // Read fresh here, not via usePrefersReducedMotion — a one-shot imperative
90 // read at scroll time, not a reactive binding (mirrors stores/audio.ts).
91 const reduce =
92 typeof window !== 'undefined' &&
93 typeof window.matchMedia === 'function' &&
94 window.matchMedia('(prefers-reduced-motion: reduce)').matches
95 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduce ? 'instant' : 'smooth' })
96 }
97 
98 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
99 // per frame. Event-driven layout sync, NOT an animation loop (nothing
100 // self-re-arms), so it needs no reduced-motion guard.
101 let frame = 0
102 function schedule() {
103 if (frame || typeof requestAnimationFrame === 'undefined') {
104 if (typeof requestAnimationFrame === 'undefined') resolve()
105 return
106 }
107 frame = requestAnimationFrame(() => {
108 frame = 0
109 resolve()
110 })
111 }
112 
113 let tracking = false
114 let ro: ResizeObserver | null = null
115 let mo: MutationObserver | null = null
116 
117 function start() {
118 if (tracking || typeof window === 'undefined') return
119 tracking = true
120 resolve()
121 window.addEventListener('scroll', schedule, { capture: true, passive: true })
122 window.addEventListener('resize', schedule, { passive: true })
123 if (typeof ResizeObserver !== 'undefined') {
124 ro = new ResizeObserver(schedule)
125 ro.observe(document.documentElement)
126 }
127 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
128 // without a scroll or resize — watch the tree so the anchor re-resolves.
129 if (typeof MutationObserver !== 'undefined') {
130 mo = new MutationObserver(schedule)
131 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
132 }
133 }
134 
135 function stop() {
136 tracking = false
137 if (frame) {
138 cancelAnimationFrame(frame)
139 frame = 0
140 }
141 if (typeof window !== 'undefined') {
142 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
143 window.removeEventListener('resize', schedule)
144 }
145 ro?.disconnect()
146 ro = null
147 mo?.disconnect()
148 mo = null
149 // Disarm here too, so the one-shot is local to this lifecycle: a beat armed
150 // but never resolved-visible (its panel never came forward) can't leave a
151 // stale latch that a later resolve() would honor on a torn-down beat.
152 pendingScroll = false
153 rect.value = null
154 present.value = false
155 }
156 
157 onMounted(() => {
158 if (selectors.value?.length) {
159 pendingScroll = true
160 start()
161 }
162 })
163 
164 watch(selectors, (list) => {
165 if (!list || !list.length) {
166 stop()
167 return
168 }
169 // A new beat's anchors — scroll its control into view once resolved (resolve()).
170 pendingScroll = true
171 if (!tracking) start()
172 else resolve()
173 })
174 
175 onBeforeUnmount(stop)
176 
177 return { rect, present }

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.

composables/useCoachAnchor.ts · 178 lines
composables/useCoachAnchor.ts178 lines · TypeScript
⋯ 80 lines hidden (lines 1–80)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges — and,
21 * on a real step change, scroll that target into view on entry (see below). The
22 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
23 * to an always-present one (the general ⚡ line).
24 *
25 * Returns present=false when no selector resolves to a *visible* element —
26 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
27 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
28 * heavy observers run only while a selector list is set (i.e. while a tour beat
29 * is on screen), so there's no idle cost on the board.
30 *
31 * On a real step change (a new selector list) the freshly-resolved control is
32 * scrolled into view the first moment it resolves visible — so the mark never
33 * points at a control rendered but scrolled out of the compose dock, the one
34 * thing a phone tab-switch alone can't fix (issue #142). Gated on visibility, it
35 * naturally waits out that tab-switch instead of racing it.
36 */
37export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
38 const rect = ref<AnchorRect | null>(null)
39 const present = ref(false)
40 
41 function measure(el: Element): AnchorRect | null {
42 // A zero-area rect is how a hidden control reads — display:none (an inactive
43 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
44 // so the caller falls back instead of anchoring to a point nobody can see.
45 const r = el.getBoundingClientRect()
46 if (r.width === 0 && r.height === 0) return null
47 return { top: r.top, left: r.left, width: r.width, height: r.height }
48 }
49 
50 function resolve() {
51 const list = selectors.value
52 if (!list || !list.length || typeof document === 'undefined') {
53 rect.value = null
54 present.value = false
55 return
56 }
57 for (const sel of list) {
58 const el = document.querySelector(sel)
59 if (!el) continue
60 const measured = measure(el)
61 if (measured) {
62 rect.value = measured
63 present.value = true
64 // First visible resolution of a new beat: bring the control into view.
65 if (pendingScroll) {
66 pendingScroll = false
67 scrollAnchorIntoView(el)
68 }
69 return
70 }
71 }
72 rect.value = null
73 present.value = false
74 }
75 
76 // A real step change asks for its anchor to be scrolled into view once it
77 // resolves visible — see resolve(). One-shot per beat: armed on entry below,
78 // disarmed by the first scroll, so repositions within the beat never re-scroll.
79 let pendingScroll = false
80 
81 // Bring the beat's freshly-resolved control into view before the mark places — a
82 // control rendered but scrolled out of the compose dock (mobile AND desktop)
83 // otherwise leaves the mark pointing at an off-screen spot (issue #142). This is
84 // the lone bit of MOTION here, so — unlike the event-driven tracking below — it
85 // honors prefers-reduced-motion, snapping instantly when motion is off. block:
86 // 'center' keeps room above and below for the card whichever way it places.
87 function scrollAnchorIntoView(el: Element) {
88 if (typeof el.scrollIntoView !== 'function') return
89 // Read fresh here, not via usePrefersReducedMotion — a one-shot imperative
90 // read at scroll time, not a reactive binding (mirrors stores/audio.ts).
91 const reduce =
92 typeof window !== 'undefined' &&
93 typeof window.matchMedia === 'function' &&
94 window.matchMedia('(prefers-reduced-motion: reduce)').matches
95 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduce ? 'instant' : 'smooth' })
96 }
⋯ 82 lines hidden (lines 97–178)
97 
98 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
99 // per frame. Event-driven layout sync, NOT an animation loop (nothing
100 // self-re-arms), so it needs no reduced-motion guard.
101 let frame = 0
102 function schedule() {
103 if (frame || typeof requestAnimationFrame === 'undefined') {
104 if (typeof requestAnimationFrame === 'undefined') resolve()
105 return
106 }
107 frame = requestAnimationFrame(() => {
108 frame = 0
109 resolve()
110 })
111 }
112 
113 let tracking = false
114 let ro: ResizeObserver | null = null
115 let mo: MutationObserver | null = null
116 
117 function start() {
118 if (tracking || typeof window === 'undefined') return
119 tracking = true
120 resolve()
121 window.addEventListener('scroll', schedule, { capture: true, passive: true })
122 window.addEventListener('resize', schedule, { passive: true })
123 if (typeof ResizeObserver !== 'undefined') {
124 ro = new ResizeObserver(schedule)
125 ro.observe(document.documentElement)
126 }
127 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
128 // without a scroll or resize — watch the tree so the anchor re-resolves.
129 if (typeof MutationObserver !== 'undefined') {
130 mo = new MutationObserver(schedule)
131 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
132 }
133 }
134 
135 function stop() {
136 tracking = false
137 if (frame) {
138 cancelAnimationFrame(frame)
139 frame = 0
140 }
141 if (typeof window !== 'undefined') {
142 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
143 window.removeEventListener('resize', schedule)
144 }
145 ro?.disconnect()
146 ro = null
147 mo?.disconnect()
148 mo = null
149 // Disarm here too, so the one-shot is local to this lifecycle: a beat armed
150 // but never resolved-visible (its panel never came forward) can't leave a
151 // stale latch that a later resolve() would honor on a torn-down beat.
152 pendingScroll = false
153 rect.value = null
154 present.value = false
155 }
156 
157 onMounted(() => {
158 if (selectors.value?.length) {
159 pendingScroll = true
160 start()
161 }
162 })
163 
164 watch(selectors, (list) => {
165 if (!list || !list.length) {
166 stop()
167 return
168 }
169 // A new beat's anchors — scroll its control into view once resolved (resolve()).
170 pendingScroll = true
171 if (!tracking) start()
172 else resolve()
173 })
174 
175 onBeforeUnmount(stop)
176 
177 return { rect, present }

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.

composables/useCoachAnchor.ts · 178 lines
composables/useCoachAnchor.ts178 lines · TypeScript
⋯ 156 lines hidden (lines 1–156)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges — and,
21 * on a real step change, scroll that target into view on entry (see below). The
22 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
23 * to an always-present one (the general ⚡ line).
24 *
25 * Returns present=false when no selector resolves to a *visible* element —
26 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
27 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
28 * heavy observers run only while a selector list is set (i.e. while a tour beat
29 * is on screen), so there's no idle cost on the board.
30 *
31 * On a real step change (a new selector list) the freshly-resolved control is
32 * scrolled into view the first moment it resolves visible — so the mark never
33 * points at a control rendered but scrolled out of the compose dock, the one
34 * thing a phone tab-switch alone can't fix (issue #142). Gated on visibility, it
35 * naturally waits out that tab-switch instead of racing it.
36 */
37export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
38 const rect = ref<AnchorRect | null>(null)
39 const present = ref(false)
40 
41 function measure(el: Element): AnchorRect | null {
42 // A zero-area rect is how a hidden control reads — display:none (an inactive
43 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
44 // so the caller falls back instead of anchoring to a point nobody can see.
45 const r = el.getBoundingClientRect()
46 if (r.width === 0 && r.height === 0) return null
47 return { top: r.top, left: r.left, width: r.width, height: r.height }
48 }
49 
50 function resolve() {
51 const list = selectors.value
52 if (!list || !list.length || typeof document === 'undefined') {
53 rect.value = null
54 present.value = false
55 return
56 }
57 for (const sel of list) {
58 const el = document.querySelector(sel)
59 if (!el) continue
60 const measured = measure(el)
61 if (measured) {
62 rect.value = measured
63 present.value = true
64 // First visible resolution of a new beat: bring the control into view.
65 if (pendingScroll) {
66 pendingScroll = false
67 scrollAnchorIntoView(el)
68 }
69 return
70 }
71 }
72 rect.value = null
73 present.value = false
74 }
75 
76 // A real step change asks for its anchor to be scrolled into view once it
77 // resolves visible — see resolve(). One-shot per beat: armed on entry below,
78 // disarmed by the first scroll, so repositions within the beat never re-scroll.
79 let pendingScroll = false
80 
81 // Bring the beat's freshly-resolved control into view before the mark places — a
82 // control rendered but scrolled out of the compose dock (mobile AND desktop)
83 // otherwise leaves the mark pointing at an off-screen spot (issue #142). This is
84 // the lone bit of MOTION here, so — unlike the event-driven tracking below — it
85 // honors prefers-reduced-motion, snapping instantly when motion is off. block:
86 // 'center' keeps room above and below for the card whichever way it places.
87 function scrollAnchorIntoView(el: Element) {
88 if (typeof el.scrollIntoView !== 'function') return
89 // Read fresh here, not via usePrefersReducedMotion — a one-shot imperative
90 // read at scroll time, not a reactive binding (mirrors stores/audio.ts).
91 const reduce =
92 typeof window !== 'undefined' &&
93 typeof window.matchMedia === 'function' &&
94 window.matchMedia('(prefers-reduced-motion: reduce)').matches
95 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduce ? 'instant' : 'smooth' })
96 }
97 
98 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
99 // per frame. Event-driven layout sync, NOT an animation loop (nothing
100 // self-re-arms), so it needs no reduced-motion guard.
101 let frame = 0
102 function schedule() {
103 if (frame || typeof requestAnimationFrame === 'undefined') {
104 if (typeof requestAnimationFrame === 'undefined') resolve()
105 return
106 }
107 frame = requestAnimationFrame(() => {
108 frame = 0
109 resolve()
110 })
111 }
112 
113 let tracking = false
114 let ro: ResizeObserver | null = null
115 let mo: MutationObserver | null = null
116 
117 function start() {
118 if (tracking || typeof window === 'undefined') return
119 tracking = true
120 resolve()
121 window.addEventListener('scroll', schedule, { capture: true, passive: true })
122 window.addEventListener('resize', schedule, { passive: true })
123 if (typeof ResizeObserver !== 'undefined') {
124 ro = new ResizeObserver(schedule)
125 ro.observe(document.documentElement)
126 }
127 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
128 // without a scroll or resize — watch the tree so the anchor re-resolves.
129 if (typeof MutationObserver !== 'undefined') {
130 mo = new MutationObserver(schedule)
131 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
132 }
133 }
134 
135 function stop() {
136 tracking = false
137 if (frame) {
138 cancelAnimationFrame(frame)
139 frame = 0
140 }
141 if (typeof window !== 'undefined') {
142 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
143 window.removeEventListener('resize', schedule)
144 }
145 ro?.disconnect()
146 ro = null
147 mo?.disconnect()
148 mo = null
149 // Disarm here too, so the one-shot is local to this lifecycle: a beat armed
150 // but never resolved-visible (its panel never came forward) can't leave a
151 // stale latch that a later resolve() would honor on a torn-down beat.
152 pendingScroll = false
153 rect.value = null
154 present.value = false
155 }
156 
157 onMounted(() => {
158 if (selectors.value?.length) {
159 pendingScroll = true
160 start()
161 }
162 })
163 
164 watch(selectors, (list) => {
165 if (!list || !list.length) {
166 stop()
167 return
168 }
169 // A new beat's anchors — scroll its control into view once resolved (resolve()).
170 pendingScroll = true
171 if (!tracking) start()
172 else resolve()
173 })
⋯ 5 lines hidden (lines 174–178)
174 
175 onBeforeUnmount(stop)
176 
177 return { rect, present }

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.

composables/useCoachAnchor.ts · 178 lines
composables/useCoachAnchor.ts178 lines · TypeScript
⋯ 144 lines hidden (lines 1–144)
1import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
2 
3/** A target's live position in viewport coordinates. */
4export interface AnchorRect {
5 top: number
6 left: number
7 width: number
8 height: number
9}
10 
11export interface CoachAnchor {
12 /** The resolved target's rect, or null when nothing's anchored/visible. */
13 rect: Ref<AnchorRect | null>
14 /** True when one of the selectors resolved to a visible element. */
15 present: Ref<boolean>
17 
18/**
19 * Resolve the first visible target among an ordered list of CSS selectors to a
20 * live viewport rect, tracked as the page scrolls, resizes, or rearranges — and,
21 * on a real step change, scroll that target into view on entry (see below). The
22 * ordered list is how a step falls back from a conditional control (the ⚡ chip)
23 * to an always-present one (the general ⚡ line).
24 *
25 * Returns present=false when no selector resolves to a *visible* element —
26 * missing, zero-area, display:none, or sitting in an inactive phone-tab panel —
27 * so the caller can detach to a corner. SSR-safe: inert until mounted, and the
28 * heavy observers run only while a selector list is set (i.e. while a tour beat
29 * is on screen), so there's no idle cost on the board.
30 *
31 * On a real step change (a new selector list) the freshly-resolved control is
32 * scrolled into view the first moment it resolves visible — so the mark never
33 * points at a control rendered but scrolled out of the compose dock, the one
34 * thing a phone tab-switch alone can't fix (issue #142). Gated on visibility, it
35 * naturally waits out that tab-switch instead of racing it.
36 */
37export function useCoachAnchor(selectors: Ref<string[] | null>): CoachAnchor {
38 const rect = ref<AnchorRect | null>(null)
39 const present = ref(false)
40 
41 function measure(el: Element): AnchorRect | null {
42 // A zero-area rect is how a hidden control reads — display:none (an inactive
43 // phone-tab panel) or an unrendered conditional chip both collapse to 0×0,
44 // so the caller falls back instead of anchoring to a point nobody can see.
45 const r = el.getBoundingClientRect()
46 if (r.width === 0 && r.height === 0) return null
47 return { top: r.top, left: r.left, width: r.width, height: r.height }
48 }
49 
50 function resolve() {
51 const list = selectors.value
52 if (!list || !list.length || typeof document === 'undefined') {
53 rect.value = null
54 present.value = false
55 return
56 }
57 for (const sel of list) {
58 const el = document.querySelector(sel)
59 if (!el) continue
60 const measured = measure(el)
61 if (measured) {
62 rect.value = measured
63 present.value = true
64 // First visible resolution of a new beat: bring the control into view.
65 if (pendingScroll) {
66 pendingScroll = false
67 scrollAnchorIntoView(el)
68 }
69 return
70 }
71 }
72 rect.value = null
73 present.value = false
74 }
75 
76 // A real step change asks for its anchor to be scrolled into view once it
77 // resolves visible — see resolve(). One-shot per beat: armed on entry below,
78 // disarmed by the first scroll, so repositions within the beat never re-scroll.
79 let pendingScroll = false
80 
81 // Bring the beat's freshly-resolved control into view before the mark places — a
82 // control rendered but scrolled out of the compose dock (mobile AND desktop)
83 // otherwise leaves the mark pointing at an off-screen spot (issue #142). This is
84 // the lone bit of MOTION here, so — unlike the event-driven tracking below — it
85 // honors prefers-reduced-motion, snapping instantly when motion is off. block:
86 // 'center' keeps room above and below for the card whichever way it places.
87 function scrollAnchorIntoView(el: Element) {
88 if (typeof el.scrollIntoView !== 'function') return
89 // Read fresh here, not via usePrefersReducedMotion — a one-shot imperative
90 // read at scroll time, not a reactive binding (mirrors stores/audio.ts).
91 const reduce =
92 typeof window !== 'undefined' &&
93 typeof window.matchMedia === 'function' &&
94 window.matchMedia('(prefers-reduced-motion: reduce)').matches
95 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduce ? 'instant' : 'smooth' })
96 }
97 
98 // rAF-coalesced so a burst of scroll/mutation events repositions at most once
99 // per frame. Event-driven layout sync, NOT an animation loop (nothing
100 // self-re-arms), so it needs no reduced-motion guard.
101 let frame = 0
102 function schedule() {
103 if (frame || typeof requestAnimationFrame === 'undefined') {
104 if (typeof requestAnimationFrame === 'undefined') resolve()
105 return
106 }
107 frame = requestAnimationFrame(() => {
108 frame = 0
109 resolve()
110 })
111 }
112 
113 let tracking = false
114 let ro: ResizeObserver | null = null
115 let mo: MutationObserver | null = null
116 
117 function start() {
118 if (tracking || typeof window === 'undefined') return
119 tracking = true
120 resolve()
121 window.addEventListener('scroll', schedule, { capture: true, passive: true })
122 window.addEventListener('resize', schedule, { passive: true })
123 if (typeof ResizeObserver !== 'undefined') {
124 ro = new ResizeObserver(schedule)
125 ro.observe(document.documentElement)
126 }
127 // Conditional controls (the ⚡ chip, the dice, a phone-tab swap) come and go
128 // without a scroll or resize — watch the tree so the anchor re-resolves.
129 if (typeof MutationObserver !== 'undefined') {
130 mo = new MutationObserver(schedule)
131 mo.observe(document.body, { childList: true, subtree: true, attributes: true })
132 }
133 }
134 
135 function stop() {
136 tracking = false
137 if (frame) {
138 cancelAnimationFrame(frame)
139 frame = 0
140 }
141 if (typeof window !== 'undefined') {
142 window.removeEventListener('scroll', schedule, { capture: true } as EventListenerOptions)
143 window.removeEventListener('resize', schedule)
144 }
145 ro?.disconnect()
146 ro = null
147 mo?.disconnect()
148 mo = null
149 // Disarm here too, so the one-shot is local to this lifecycle: a beat armed
150 // but never resolved-visible (its panel never came forward) can't leave a
151 // stale latch that a later resolve() would honor on a torn-down beat.
152 pendingScroll = false
153 rect.value = null
154 present.value = false
155 }
⋯ 23 lines hidden (lines 156–178)
156 
157 onMounted(() => {
158 if (selectors.value?.length) {
159 pendingScroll = true
160 start()
161 }
162 })
163 
164 watch(selectors, (list) => {
165 if (!list || !list.length) {
166 stop()
167 return
168 }
169 // A new beat's anchors — scroll its control into view once resolved (resolve()).
170 pendingScroll = true
171 if (!tracking) start()
172 else resolve()
173 })
174 
175 onBeforeUnmount(stop)
176 
177 return { rect, present }

Tests pin each rule

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.

tests/unit/composables/useCoachAnchor.nuxt.dom.spec.ts · 248 lines
tests/unit/composables/useCoachAnchor.nuxt.dom.spec.ts248 lines · TypeScript
⋯ 209 lines hidden (lines 1–209)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { defineComponent, h, ref, nextTick, type VNode } from 'vue'
3import { mount, type VueWrapper } from '@vue/test-utils'
4import { useCoachAnchor } from '../../../composables/useCoachAnchor'
5 
6// DOM env (nuxt) so document / lifecycle hooks / getBoundingClientRect exist. The
7// composable resolves the FIRST visible selector to a live rect; happy-dom has no
8// layout engine, so each target's rect is stubbed (a 0×0 rect is "hidden").
9 
10const wrappers: VueWrapper[] = []
11 
12function addTarget(testid: string, rect: Partial<DOMRect> = {}) {
13 const el = document.createElement('div')
14 el.setAttribute('data-testid', testid)
15 document.body.appendChild(el)
16 const r = { top: 10, left: 20, width: 100, height: 30, right: 120, bottom: 40, x: 20, y: 10, ...rect }
17 el.getBoundingClientRect = () => ({ ...r, toJSON: () => r }) as DOMRect
18 return el
20 
21function makeHost(initial: string[] | null) {
22 const sel = ref<string[] | null>(initial)
23 let anchor!: ReturnType<typeof useCoachAnchor>
24 const Host = defineComponent({
25 setup() {
26 anchor = useCoachAnchor(sel)
27 return (): VNode => h('div', { 'data-host': '' })
28 }
29 })
30 const wrapper = mount(Host, { attachTo: document.body })
31 wrappers.push(wrapper)
32 return { sel, anchor }
34 
35describe('useCoachAnchor', () => {
36 beforeEach(() => { document.body.innerHTML = '' })
37 afterEach(() => {
38 wrappers.splice(0).forEach((w) => w.unmount())
39 document.body.innerHTML = ''
40 })
41 
42 it('resolves a present target to its viewport rect', async () => {
43 addTarget('alpha')
44 const { anchor } = makeHost(['[data-testid="alpha"]'])
45 await nextTick()
46 expect(anchor.present.value).toBe(true)
47 expect(anchor.rect.value).toMatchObject({ top: 10, left: 20, width: 100, height: 30 })
48 })
49 
50 it('reports absent when no selector matches', async () => {
51 const { anchor } = makeHost(['[data-testid="nope"]'])
52 await nextTick()
53 expect(anchor.present.value).toBe(false)
54 expect(anchor.rect.value).toBeNull()
55 })
56 
57 it('falls back to the next selector when the primary is absent', async () => {
58 addTarget('fallback', { left: 5 })
59 const { anchor } = makeHost(['[data-testid="primary-missing"]', '[data-testid="fallback"]'])
60 await nextTick()
61 expect(anchor.present.value).toBe(true)
62 expect(anchor.rect.value?.left).toBe(5)
63 })
64 
65 it('prefers the primary over the fallback when both are present', async () => {
66 addTarget('primary', { left: 1 })
67 addTarget('fallback', { left: 2 })
68 const { anchor } = makeHost(['[data-testid="primary"]', '[data-testid="fallback"]'])
69 await nextTick()
70 expect(anchor.rect.value?.left).toBe(1)
71 })
72 
73 it('treats a zero-area (hidden / inactive-tab) target as absent', async () => {
74 addTarget('hidden', { width: 0, height: 0 })
75 const { anchor } = makeHost(['[data-testid="hidden"]'])
76 await nextTick()
77 expect(anchor.present.value).toBe(false)
78 })
79 
80 it('re-resolves when the selector list changes', async () => {
81 addTarget('a')
82 addTarget('b', { left: 99 })
83 const { anchor, sel } = makeHost(['[data-testid="a"]'])
84 await nextTick()
85 expect(anchor.rect.value?.left).toBe(20)
86 sel.value = ['[data-testid="b"]']
87 await nextTick()
88 expect(anchor.rect.value?.left).toBe(99)
89 })
90 
91 it('clears when the selector list becomes null', async () => {
92 addTarget('a')
93 const { anchor, sel } = makeHost(['[data-testid="a"]'])
94 await nextTick()
95 expect(anchor.present.value).toBe(true)
96 sel.value = null
97 await nextTick()
98 expect(anchor.present.value).toBe(false)
99 expect(anchor.rect.value).toBeNull()
100 })
101 
102 // ── Scroll-into-view on step entry (issue #142) ───────────────────────────
103 // A control rendered but scrolled out of the compose dock has a real (non-zero)
104 // rect, so it resolves "present" yet sits off-screen. On a real beat change the
105 // composable scrolls its freshly-resolved control into view so the mark never
106 // points at a spot nobody can see. Each target's scrollIntoView is spied.
107 
108 // A matchMedia stub — the composable reads only `.matches`; the full shape keeps
109 // it assignable without a cast. The factory lets a test pin reduced-motion on/off
110 // instead of leaning on the env default.
111 const makeMatchMedia = (matches: boolean) => (query: string): MediaQueryList => ({
112 matches,
113 media: query,
114 onchange: null,
115 addListener: () => {},
116 removeListener: () => {},
117 addEventListener: () => {},
118 removeEventListener: () => {},
119 dispatchEvent: () => false
120 })
121 
122 // Makes a freshly-laid-out rect for a target that started hidden (0×0).
123 const visibleRect = (): DOMRect => {
124 const r = { top: 10, left: 20, width: 100, height: 30, right: 120, bottom: 40, x: 20, y: 10 }
125 return { ...r, toJSON: () => r } as DOMRect
126 }
127 
128 it('scrolls the resolved anchor into view when a beat begins', async () => {
129 const original = window.matchMedia
130 window.matchMedia = makeMatchMedia(false) // motion on — pin it, don't lean on the env default
131 try {
132 const el = addTarget('alpha')
133 el.scrollIntoView = vi.fn()
134 makeHost(['[data-testid="alpha"]'])
135 await nextTick()
136 expect(el.scrollIntoView).toHaveBeenCalledTimes(1)
137 expect(el.scrollIntoView).toHaveBeenCalledWith(
138 expect.objectContaining({ block: 'center', inline: 'nearest', behavior: 'smooth' })
139 )
140 } finally {
141 window.matchMedia = original
142 }
143 })
144 
145 it('scrolls the new anchor when the beat changes', async () => {
146 const a = addTarget('a')
147 const b = addTarget('b', { left: 99 })
148 a.scrollIntoView = vi.fn()
149 b.scrollIntoView = vi.fn()
150 const { sel } = makeHost(['[data-testid="a"]'])
151 await nextTick()
152 expect(a.scrollIntoView).toHaveBeenCalledTimes(1)
153 sel.value = ['[data-testid="b"]']
154 await nextTick()
155 expect(b.scrollIntoView).toHaveBeenCalledTimes(1)
156 })
157 
158 it('scrolls the fallback element when the primary anchor is absent', async () => {
159 // The wager beat is the one with a fallback (⚡ chip → ⚡ line); scrolling must
160 // follow the same fallthrough resolution the rect does.
161 const fallback = addTarget('fallback', { left: 5 })
162 fallback.scrollIntoView = vi.fn()
163 makeHost(['[data-testid="primary-missing"]', '[data-testid="fallback"]'])
164 await nextTick()
165 expect(fallback.scrollIntoView).toHaveBeenCalledTimes(1)
166 })
167 
168 it('snaps instantly (no smooth animation) under prefers-reduced-motion', async () => {
169 const original = window.matchMedia
170 window.matchMedia = makeMatchMedia(true)
171 try {
172 const el = addTarget('alpha')
173 el.scrollIntoView = vi.fn()
174 makeHost(['[data-testid="alpha"]'])
175 await nextTick()
176 expect(el.scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'instant' }))
177 } finally {
178 window.matchMedia = original
179 }
180 })
181 
182 it('does not scroll when nothing resolves visible', async () => {
183 const hidden = addTarget('hidden', { width: 0, height: 0 })
184 hidden.scrollIntoView = vi.fn()
185 makeHost(['[data-testid="hidden"]'])
186 await nextTick()
187 expect(hidden.scrollIntoView).not.toHaveBeenCalled()
188 })
189 
190 it('defers a hidden control, then flushes its scroll on a later reposition', async () => {
191 // rAF runs in-band so the reposition resolves synchronously.
192 vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0 })
193 try {
194 const el = addTarget('late', { width: 0, height: 0 }) // hidden: its phone-tab panel isn't forward yet
195 el.scrollIntoView = vi.fn()
196 makeHost(['[data-testid="late"]'])
197 await nextTick()
198 expect(el.scrollIntoView).not.toHaveBeenCalled()
199 // The panel comes forward — the control gains a real rect…
200 el.getBoundingClientRect = visibleRect
201 // …and the next reposition flushes the still-armed scroll, exactly once.
202 window.dispatchEvent(new Event('resize'))
203 await nextTick()
204 expect(el.scrollIntoView).toHaveBeenCalledTimes(1)
205 } finally {
206 vi.unstubAllGlobals()
207 }
208 })
209 
210 it('flushes the deferred scroll via the MutationObserver when the panel comes forward', async () => {
211 // The real #142 mobile seam: the tab section's `hidden` class toggles, the
212 // body-subtree observer catches it, and resolve() re-runs — no scroll/resize.
213 vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0 })
214 try {
215 const el = addTarget('panelled', { width: 0, height: 0 }) // hidden in an inactive tab panel
216 el.scrollIntoView = vi.fn()
217 makeHost(['[data-testid="panelled"]'])
218 await nextTick()
219 expect(el.scrollIntoView).not.toHaveBeenCalled()
220 // The control gains a real rect, and its class flips — the exact attribute
221 // mutation the observer watches.
222 el.getBoundingClientRect = visibleRect
223 el.setAttribute('class', 'now-visible')
224 await nextTick()
225 await nextTick()
226 expect(el.scrollIntoView).toHaveBeenCalledTimes(1)
227 } finally {
228 vi.unstubAllGlobals()
229 }
230 })
⋯ 18 lines hidden (lines 231–248)
231 
232 it("scrolls a beat's anchor once, not on every reposition", async () => {
233 vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0 })
234 try {
235 const el = addTarget('alpha')
236 el.scrollIntoView = vi.fn()
237 makeHost(['[data-testid="alpha"]'])
238 await nextTick()
239 expect(el.scrollIntoView).toHaveBeenCalledTimes(1) // on entry
240 window.dispatchEvent(new Event('scroll'))
241 window.dispatchEvent(new Event('resize'))
242 await nextTick()
243 expect(el.scrollIntoView).toHaveBeenCalledTimes(1) // still once
244 } finally {
245 vi.unstubAllGlobals()
246 }
247 })
248})

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.