diff --git a/client/src/components/shared/TripLoadingSplash.tsx b/client/src/components/shared/TripLoadingSplash.tsx
index 47f626432..c3bcdec9a 100644
--- a/client/src/components/shared/TripLoadingSplash.tsx
+++ b/client/src/components/shared/TripLoadingSplash.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n'
-import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTrek'
+import MDancingTrek from '../../mobile/components/MDancingTrek'
+import { useTripSplashBeats } from './useTripSplashBeats'
/**
* Trip-open splash for desktop — the same little journey the mobile splash
@@ -15,15 +15,6 @@ import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTr
* freezes and the mascot parks on "loading photos". Palette + glass tint swap
* for light/dark via the .dark class the app puts on .
*/
-const STEPS: { scene: TrekScene; key: string }[] = [
- { scene: 'packing', key: 'trip.loadingSteps.pack' },
- { scene: 'transport', key: 'trip.loadingSteps.road' },
- { scene: 'dashboard', key: 'trip.loadingPhotos' },
- { scene: 'collections', key: 'trip.loadingSteps.arrive' },
-]
-const STEP_MS = 1400
-const STILL_INDEX = 2
-
const SPLASH_CSS = `
.m-splash-root { background: #eef0fb; }
.dark .m-splash-root { background: #0c0c12; }
@@ -158,18 +149,7 @@ const SPLASH_CSS = `
export default function TripLoadingSplash({ title }: { title?: string }) {
const { t } = useTranslation()
- const reduceMotion =
- typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
-
- const [index, setIndex] = useState(0)
- useEffect(() => {
- if (reduceMotion) return
- const id = setInterval(() => setIndex((n) => (n + 1) % STEPS.length), STEP_MS)
- return () => clearInterval(id)
- }, [reduceMotion])
-
- const activeIndex = reduceMotion ? STILL_INDEX : index
- const step = STEPS[activeIndex]
+ const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: false })
return (
- {STEPS.map((_, i) => (
+ {steps.map((_, i) => (
void;
+
+function stubReducedMotion(initial: boolean) {
+ const mq = {
+ matches: initial,
+ listeners: new Set(),
+ addEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.add(fn)),
+ removeEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.delete(fn)),
+ };
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn(() => mq),
+ });
+ return {
+ mq,
+ flip(next: boolean) {
+ mq.matches = next;
+ mq.listeners.forEach((fn) => fn());
+ },
+ };
+}
+
+const originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia');
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+afterEach(() => {
+ vi.useRealTimers();
+ if (originalMatchMedia) Object.defineProperty(window, 'matchMedia', originalMatchMedia);
+ else delete (window as { matchMedia?: unknown }).matchMedia;
+});
+
+describe('useTripSplashBeats', () => {
+ it('FE-COMP-SPLASHBEATS-001: holds the four beats, 1400 ms apart, parking on the third', () => {
+ expect(SPLASH_STEPS.map((s) => s.scene)).toEqual(['packing', 'transport', 'dashboard', 'collections']);
+ expect(SPLASH_STEP_MS).toBe(1400);
+ expect(SPLASH_STILL_INDEX).toBe(2);
+ expect(SPLASH_STEPS[SPLASH_STILL_INDEX].key).toBe('trip.loadingPhotos');
+ });
+
+ it.each([false, true])(
+ 'FE-COMP-SPLASHBEATS-002: steps through the beats and loops (liveReducedMotion %s)',
+ (liveReducedMotion) => {
+ stubReducedMotion(false);
+ const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion }));
+ expect(result.current.activeIndex).toBe(0);
+ expect(result.current.step).toBe(SPLASH_STEPS[0]);
+ expect(result.current.steps).toBe(SPLASH_STEPS);
+ act(() => vi.advanceTimersByTime(SPLASH_STEP_MS - 1));
+ expect(result.current.activeIndex).toBe(0);
+ act(() => vi.advanceTimersByTime(1));
+ expect(result.current.activeIndex).toBe(1);
+ act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 3));
+ expect(result.current.activeIndex).toBe(0);
+ expect(result.current.reduceMotion).toBeFalsy();
+ }
+ );
+
+ it.each([false, true])(
+ 'FE-COMP-SPLASHBEATS-003: under reduced motion parks on the still beat (liveReducedMotion %s)',
+ (liveReducedMotion) => {
+ stubReducedMotion(true);
+ const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion }));
+ expect(result.current.reduceMotion).toBe(true);
+ expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX);
+ act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 5));
+ expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX);
+ expect(result.current.step).toBe(SPLASH_STEPS[SPLASH_STILL_INDEX]);
+ }
+ );
+
+ it('FE-COMP-SPLASHBEATS-004: live, follows the setting flipped while the splash is up and unsubscribes', () => {
+ const media = stubReducedMotion(false);
+ const { result, unmount } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true }));
+ expect(media.mq.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ act(() => media.flip(true));
+ expect(result.current.reduceMotion).toBe(true);
+ expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX);
+ act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 2));
+ expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX);
+ act(() => media.flip(false));
+ expect(result.current.reduceMotion).toBe(false);
+ unmount();
+ expect(media.mq.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ expect(media.mq.listeners.size).toBe(0);
+ });
+
+ it('FE-COMP-SPLASHBEATS-005: not live, never subscribes and picks up the setting on the next render', () => {
+ const media = stubReducedMotion(false);
+ const { result, rerender } = renderHook(() => useTripSplashBeats({ liveReducedMotion: false }));
+ expect(media.mq.addEventListener).not.toHaveBeenCalled();
+ act(() => media.flip(true));
+ expect(result.current.reduceMotion).toBe(false);
+ rerender();
+ expect(result.current.reduceMotion).toBe(true);
+ expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX);
+ });
+
+ it('FE-COMP-SPLASHBEATS-006: live without matchMedia, plays the beats', () => {
+ Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined });
+ const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true }));
+ expect(result.current.reduceMotion).toBe(false);
+ act(() => vi.advanceTimersByTime(SPLASH_STEP_MS));
+ expect(result.current.activeIndex).toBe(1);
+ });
+});
diff --git a/client/src/components/shared/useTripSplashBeats.ts b/client/src/components/shared/useTripSplashBeats.ts
new file mode 100644
index 000000000..73c1c51ea
--- /dev/null
+++ b/client/src/components/shared/useTripSplashBeats.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+
+/** The beats of the trip-open splash: packing, hitting the road, cruising in, dropping the pin. */
+export const SPLASH_STEPS = [
+ { scene: 'packing', key: 'trip.loadingSteps.pack' },
+ { scene: 'transport', key: 'trip.loadingSteps.road' },
+ { scene: 'dashboard', key: 'trip.loadingPhotos' },
+ { scene: 'collections', key: 'trip.loadingSteps.arrive' },
+] as const;
+
+export const SPLASH_STEP_MS = 1400;
+// Reduced motion parks on the paper-plane / "loading photos" beat.
+export const SPLASH_STILL_INDEX = 2;
+const REDUCE_MOTION = '(prefers-reduced-motion: reduce)';
+
+interface TripSplashBeatsOptions {
+ /**
+ * Follow the OS reduced motion setting while the splash is up (the phone, whose splash can be
+ * up long enough for the setting to be flipped under it). Off, the setting is read on each render.
+ */
+ liveReducedMotion: boolean;
+}
+
+/**
+ * The beat cycle both trip-open splashes play: steps through SPLASH_STEPS every SPLASH_STEP_MS
+ * and loops; under reduced motion it holds SPLASH_STILL_INDEX instead.
+ */
+export function useTripSplashBeats({ liveReducedMotion }: TripSplashBeatsOptions) {
+ const [liveReduce, setLiveReduce] = useState(() =>
+ liveReducedMotion ? (window.matchMedia?.(REDUCE_MOTION)?.matches ?? false) : false
+ );
+ useEffect(() => {
+ if (!liveReducedMotion) return;
+ const mq = window.matchMedia?.(REDUCE_MOTION);
+ if (!mq) return;
+ const sync = () => setLiveReduce(mq.matches);
+ sync();
+ mq.addEventListener('change', sync);
+ return () => mq.removeEventListener('change', sync);
+ }, [liveReducedMotion]);
+
+ const reduceMotion = liveReducedMotion
+ ? liveReduce
+ : typeof window !== 'undefined' && window.matchMedia?.(REDUCE_MOTION).matches;
+
+ const [index, setIndex] = useState(0);
+ useEffect(() => {
+ if (reduceMotion) return;
+ const id = setInterval(() => setIndex((n) => (n + 1) % SPLASH_STEPS.length), SPLASH_STEP_MS);
+ return () => clearInterval(id);
+ }, [reduceMotion]);
+
+ const activeIndex = reduceMotion ? SPLASH_STILL_INDEX : index;
+ return { reduceMotion, activeIndex, step: SPLASH_STEPS[activeIndex], steps: SPLASH_STEPS };
+}
diff --git a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
index 3eabc739a..6987fd021 100644
--- a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
+++ b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
import { useTranslation } from '../../../i18n'
-import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek'
+import MDancingTrek from '../../components/MDancingTrek'
+import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
/**
* Trip-open splash — the TREK mascot acts out a little journey while the trip
@@ -10,40 +10,9 @@ import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek'
* progress dots track which beat is on. Under reduced motion it holds a single
* frame (the real "loading photos" one) with no transitions.
*/
-const STEPS: { scene: TrekScene; key: string }[] = [
- { scene: 'packing', key: 'trip.loadingSteps.pack' },
- { scene: 'transport', key: 'trip.loadingSteps.road' },
- { scene: 'dashboard', key: 'trip.loadingPhotos' },
- { scene: 'collections', key: 'trip.loadingSteps.arrive' },
-]
-
-const STEP_MS = 1400
-// Reduced motion parks on the paper-plane / "loading photos" beat.
-const STILL_INDEX = 2
-const REDUCE_MOTION = '(prefers-reduced-motion: reduce)'
-
export default function MTripLoadingSplash({ title }: { title: string }) {
const { t } = useTranslation()
- const [reduceMotion, setReduceMotion] = useState(() => window.matchMedia?.(REDUCE_MOTION)?.matches ?? false)
- // The splash can be up long enough for the OS setting to be flipped under it.
- useEffect(() => {
- const mq = window.matchMedia?.(REDUCE_MOTION)
- if (!mq) return
- const sync = () => setReduceMotion(mq.matches)
- sync()
- mq.addEventListener('change', sync)
- return () => mq.removeEventListener('change', sync)
- }, [])
-
- const [index, setIndex] = useState(0)
- useEffect(() => {
- if (reduceMotion) return
- const id = setInterval(() => setIndex(n => (n + 1) % STEPS.length), STEP_MS)
- return () => clearInterval(id)
- }, [reduceMotion])
-
- const activeIndex = reduceMotion ? STILL_INDEX : index
- const step = STEPS[activeIndex]
+ const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
return (
@@ -64,7 +33,7 @@ export default function MTripLoadingSplash({ title }: { title: string }) {
{/* Beat dots — the active stage widens into an accent pill. */}
- {STEPS.map((_, i) => (
+ {steps.map((_, i) => (