@@ -15,6 +15,8 @@ let raf = 0;
1515let running = false ;
1616let reduced = false ;
1717let hovering = false ;
18+ let heroVisible = false ;
19+ let heroObserver: IntersectionObserver | null = null ;
1820
1921// Coordinates are relative to the hero's top-left.
2022const target = { x: 0 , y: 0 }; // cursor position, offset to sit above the cursor
@@ -24,7 +26,7 @@ const dest = { x: 0, y: 0 }; // where the current creep ends
2426
2527const OFFSET_X = - 4 ; // sit a tiny bit to the left of the cursor
2628const OFFSET_Y = - 26 ; // hover a bit above the cursor
27- const IDLE_DELAY = 1600 ; // cursor must be still this long before it dares appear
29+ const IDLE_DELAY = 2600 ; // cursor must be still this long before it dares appear
2830const MOVE_EPS = 5 ; // movement under this counts as "still"
2931const SWAY_AMP = 2 ; // tiny left-right sway while scanning
3032
@@ -136,7 +138,11 @@ function frame(now: number) {
136138
137139 if (phase === " hidden" ) {
138140 scaleVal = 0 ;
139- if (hovering && now - lastMoveTime > IDLE_DELAY ) startAppear (now );
141+ // Re-checked at the moment of appearing (not just at pointerenter): the
142+ // hero must still be on screen and the page still in dark mode, so a
143+ // scroll-away or a theme switch mid-run can't summon an invisible UFO.
144+ if (hovering && heroVisible && isDark () && now - lastMoveTime > IDLE_DELAY )
145+ startAppear (now );
140146 } else if (phase === " appear" ) {
141147 const t = clamp01 ((now - tStart ) / tDur );
142148 scaleVal = easeOut (t );
@@ -171,11 +177,20 @@ function frame(now: number) {
171177 pos .y = from .y + (dest .y - from .y ) * e ;
172178 if (t >= 1 ) {
173179 gliding = false ;
174- phase = " scan" ;
175- scanning .value = true ;
176- scanRestX = pos .x ;
177- scanRestY = pos .y ;
178- scanStart = now ;
180+ // Only start scanning (and the cursor-cycle gag) when the cursor is
181+ // actually still being watched: pointer inside the hero, hero on
182+ // screen, dark mode. If the pointer left mid-approach (the 3s linger
183+ // keeps the machine running), flee instead of scanning a cursor that
184+ // is somewhere else on the page.
185+ if (! hovering || ! heroVisible || ! isDark ()) {
186+ startFlee (now );
187+ } else {
188+ phase = " scan" ;
189+ scanning .value = true ;
190+ scanRestX = pos .x ;
191+ scanRestY = pos .y ;
192+ scanStart = now ;
193+ }
179194 }
180195 } else if (phase === " scan" ) {
181196 // tiny left-right sway while it scans
@@ -259,13 +274,35 @@ function onLeave() {
259274 }, 3000 );
260275}
261276
277+ // Scrolling moves the hero under a stationary cursor without firing any
278+ // pointer events, which would otherwise leave `hovering` and the idle timer
279+ // stale (the UFO then "randomly" appears while the user reads another
280+ // section). Treat scrolling as cursor activity and spook the UFO like any
281+ // other movement.
282+ function onScroll() {
283+ lastMoveTime = performance .now ();
284+ if (phase !== " hidden" && phase !== " flee" ) startFlee (performance .now ());
285+ }
286+
262287onMounted (() => {
263288 reduced = window .matchMedia (" (prefers-reduced-motion: reduce)" ).matches ;
264289 hero = root .value ?.parentElement ?? null ;
265290 if (! hero ) return ;
266291 hero .addEventListener (" pointermove" , onMove );
267292 hero .addEventListener (" pointerenter" , onEnter );
268293 hero .addEventListener (" pointerleave" , onLeave );
294+ window .addEventListener (" scroll" , onScroll , { passive: true });
295+
296+ // Hard stop (which also tears down the cursor cycle) whenever the hero
297+ // scrolls out of view: the UFO must never act while it can't be seen.
298+ heroObserver = new IntersectionObserver (
299+ ([entry ]) => {
300+ heroVisible = entry ?.isIntersecting ?? false ;
301+ if (! heroVisible && running ) stop ();
302+ },
303+ { threshold: 0.2 },
304+ );
305+ heroObserver .observe (hero );
269306});
270307
271308onBeforeUnmount (() => {
@@ -274,6 +311,8 @@ onBeforeUnmount(() => {
274311 hero .removeEventListener (" pointerenter" , onEnter );
275312 hero .removeEventListener (" pointerleave" , onLeave );
276313 }
314+ window .removeEventListener (" scroll" , onScroll );
315+ heroObserver ?.disconnect ();
277316 if (leaveTimer ) clearTimeout (leaveTimer );
278317 stopCursorCycle ();
279318 cancelAnimationFrame (raf );
0 commit comments