diff --git a/CHANGELOG.md b/CHANGELOG.md
index 758acb08..c038fd7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,7 +20,7 @@
### Improvements
-- Admin map: mission-control layout on tablet and desktop with a collapsible side rail for diagnostics, sync, stats, map debug, log, chat, and moderation.
+- Motion: route changes show a loading overlay until the destination screen is ready, then play the existing slide transition.
- Motion: unified sheet and panel drag physics, iOS-calibrated motion tokens, and expanded tap feedback with Vibration API fallback on web.
- Motion: iOS-style route push/pop transitions, global edge-swipe back, and unified session exit so map teardown runs after the home transition.
- Motion: retuned mobile sheets, wizard steps, and map panel snap to iOS-calibrated timing on the critical path.
diff --git a/src/App.tsx b/src/App.tsx
index ae21591d..5346ce9a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,8 +2,6 @@ import { Suspense, useEffect, type ReactNode } from "react";
import * as Sentry from "@sentry/react";
import {
BrowserRouter,
- Link,
- Navigate,
Route,
Routes,
useLocation,
@@ -14,6 +12,7 @@ import { AppUpdateBanner } from "./components/ui/AppUpdateBanner";
import { AppUpdateProvider } from "./components/ui/AppUpdateProvider";
import { LowBatteryPrompt } from "./components/session/LowBatteryPrompt";
import { MotionDatasetEffect } from "./components/motion/MotionDatasetEffect";
+import { AppLink } from "./components/navigation/AppLink";
import { Home } from "./routes/Home";
import { AdminPanel } from "./routes/AdminPanel";
import { JoinSession } from "./routes/JoinSession";
@@ -29,29 +28,23 @@ import {
} from "./domain/device/chunkLoadRecovery";
import {
getServiceWorkerChunkReloadContext,
- lazyWithChunkRetry,
setChunkReloadContextGetter,
} from "./domain/device/lazyWithChunkRetry";
import { notifyAppNeedRefresh } from "./domain/device/serviceWorkerRefresh";
import { useEdgeSwipeBack } from "./hooks/useEdgeSwipeBack";
import { pruneStaleTimerSessions } from "./services/session/sessionCleanup";
import { useSessionStore } from "./state/sessionStore";
-
-const MapScreen = lazyWithChunkRetry(() =>
- import("./routes/MapScreen").then((m) => ({ default: m.MapScreen })),
-);
-const CreateSession = lazyWithChunkRetry(() =>
- import("./routes/CreateSession").then((m) => ({ default: m.CreateSession })),
-);
-const GamePresetList = lazyWithChunkRetry(() =>
- import("./routes/GamePresets").then((m) => ({ default: m.GamePresetList })),
-);
-const GamePresetEditor = lazyWithChunkRetry(() =>
- import("./routes/GamePresets").then((m) => ({ default: m.GamePresetEditor })),
-);
-const Tutorial = lazyWithChunkRetry(() =>
- import("./routes/Tutorial").then((m) => ({ default: m.Tutorial })),
-);
+import { AppNavigate } from "./navigation/AppNavigate";
+import { RouteReadinessSensor } from "./navigation/RouteReadinessSensor";
+import { RouteTransitionOverlay } from "./navigation/RouteTransitionOverlay";
+import { RouteTransitionProvider } from "./navigation/RouteTransitionContext";
+import {
+ CreateSessionLazy,
+ GamePresetEditorLazy,
+ GamePresetListLazy,
+ MapScreenLazy,
+ TutorialLazy,
+} from "./navigation/routePreloaders";
function RouteFallback() {
return (
@@ -130,12 +123,12 @@ function AppErrorFallback() {
>
Reload
-
Back home
-
+
);
@@ -159,78 +152,82 @@ export default function App() {
return (
-
- }>
-
-
-
-
-
-
-
-
- } />
-
-
-
- }
- />
- } />
- } />
- } />
- } />
-
-
-
- }
- />
- } />
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
-
-
- }
- />
- } />
-
-
-
-
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+ } />
+
+
+
+
+
);
}
diff --git a/src/components/billing/PremiumTierCards.tsx b/src/components/billing/PremiumTierCards.tsx
index c0ce7887..c7ffa5e4 100644
--- a/src/components/billing/PremiumTierCards.tsx
+++ b/src/components/billing/PremiumTierCards.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { SegmentControl } from "../ui/SegmentControl";
import {
formatBankedPremiumSessionCreditsLabel,
@@ -191,14 +191,14 @@ export function PremiumTierCards({
) : null}
{entitlements?.canCreatePremium ? (
-
Create premium session
{createSessionHint}
-
+
) : null}
>
diff --git a/src/components/legal/LegalDocumentPage.tsx b/src/components/legal/LegalDocumentPage.tsx
index 8b5f4ae5..4551ef6b 100644
--- a/src/components/legal/LegalDocumentPage.tsx
+++ b/src/components/legal/LegalDocumentPage.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { EntryScreenLayout } from "../ui/EntryScreenLayout";
import {
ScreenHeader,
@@ -77,12 +77,12 @@ export function LegalDocumentPage({
See also{" "}
-
{otherLabel}
-
+
.
diff --git a/src/components/legal/LegalInlineLinks.tsx b/src/components/legal/LegalInlineLinks.tsx
index 484735af..dce889a5 100644
--- a/src/components/legal/LegalInlineLinks.tsx
+++ b/src/components/legal/LegalInlineLinks.tsx
@@ -1,23 +1,23 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { LEGAL_PRIVACY_PATH, LEGAL_TERMS_PATH } from "../../domain/legal/legalContact";
export function LegalInlineLinks() {
return (
See{" "}
-
Terms
- {" "}
+ {" "}
and{" "}
-
Privacy
-
+
.
);
diff --git a/src/components/navigation/AppLink.tsx b/src/components/navigation/AppLink.tsx
new file mode 100644
index 00000000..f422c1b6
--- /dev/null
+++ b/src/components/navigation/AppLink.tsx
@@ -0,0 +1,83 @@
+import {
+ Link,
+ useNavigate,
+ type LinkProps,
+} from "react-router-dom";
+import { useRouteTransition } from "../../navigation/useRouteTransition";
+
+export function AppLink({
+ to,
+ onClick,
+ target,
+ replace,
+ state,
+ preventScrollReset,
+ relative,
+ reloadDocument,
+ ...props
+}: LinkProps) {
+ const { beginTransition } = useRouteTransition();
+ const navigate = useNavigate();
+
+ const bypassTransition =
+ reloadDocument === true || (target !== undefined && target !== "_self");
+
+ if (bypassTransition) {
+ return (
+
+ );
+ }
+
+ const transitionOptions = {
+ replace,
+ state,
+ preventScrollReset,
+ relative,
+ };
+
+ return (
+ {
+ onClick?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+ if (
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey ||
+ event.button !== 0
+ ) {
+ return;
+ }
+ if (target === "_blank") {
+ return;
+ }
+
+ event.preventDefault();
+ void beginTransition(to, transitionOptions).catch(() => {
+ navigate(to, { ...transitionOptions, viewTransition: false });
+ });
+ }}
+ />
+ );
+}
diff --git a/src/components/presets/BundledPresetTree.tsx b/src/components/presets/BundledPresetTree.tsx
index de0d6e9a..82dc7894 100644
--- a/src/components/presets/BundledPresetTree.tsx
+++ b/src/components/presets/BundledPresetTree.tsx
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from "react";
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import {
BUNDLED_GAME_PRESET_DEFINITIONS,
bundledPresetDefinition,
@@ -29,12 +29,12 @@ function PresetLeafCard({ preset }: { preset: MigratedPreset }) {
description={description}
headerAction={}
actions={
-
Host
-
+
}
/>
);
diff --git a/src/components/presets/PresetBrowseLayout.tsx b/src/components/presets/PresetBrowseLayout.tsx
index 402c85ac..6b33af59 100644
--- a/src/components/presets/PresetBrowseLayout.tsx
+++ b/src/components/presets/PresetBrowseLayout.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import {
ScreenHeader,
screenHeaderOffsetClassName,
@@ -59,9 +59,9 @@ export function PresetBrowseLayout({
/>
-
+
New preset
-
+
{searching ? (
diff --git a/src/components/presets/PresetDetailPanel.tsx b/src/components/presets/PresetDetailPanel.tsx
index 0ae987ba..f683bcd6 100644
--- a/src/components/presets/PresetDetailPanel.tsx
+++ b/src/components/presets/PresetDetailPanel.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import {
bundledPresetDefinition,
isBundledPresetId,
@@ -52,28 +52,28 @@ export function PresetDetailPanel({
{preset.migrationStatus === "manual_required" ? (
-
Review
-
+
) : (
-
Host
-
+
)}
{!bundled ? (
<>
-
Edit
-
+
);
diff --git a/src/components/tutorial/TutorialHub.tsx b/src/components/tutorial/TutorialHub.tsx
index 9cd8a9ca..9ffe497c 100644
--- a/src/components/tutorial/TutorialHub.tsx
+++ b/src/components/tutorial/TutorialHub.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import {
getTutorialSections,
type TutorialSection,
@@ -131,10 +131,10 @@ export function TutorialHub({
{otherSections.map(renderSectionButton)}
-
+
Create session
Start hosting
-
+
);
}
diff --git a/src/components/tutorial/TutorialSectionWizard.tsx b/src/components/tutorial/TutorialSectionWizard.tsx
index 5a0a5f37..a26b70a8 100644
--- a/src/components/tutorial/TutorialSectionWizard.tsx
+++ b/src/components/tutorial/TutorialSectionWizard.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { HudToolIcon } from "../map/ToolIcons";
import { ToolStepper } from "../tools/shared/ToolStepper";
import { WizardSwipeSurface } from "../tools/shared/WizardSwipeSurface";
@@ -165,9 +165,9 @@ export function TutorialSectionWizard({
·
-
+
Create session
-
+
·
diff --git a/src/components/ui/MapErrorBoundary.tsx b/src/components/ui/MapErrorBoundary.tsx
index 357df689..8cbc6830 100644
--- a/src/components/ui/MapErrorBoundary.tsx
+++ b/src/components/ui/MapErrorBoundary.tsx
@@ -1,5 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import {
clearChunkReloadFlag,
hasChunkReloadBeenAttempted,
@@ -94,12 +94,12 @@ export class MapErrorBoundary extends Component<
) : null}
)}
-
{appUpdateCopy.mapErrorBackHome}
-
+
);
}
diff --git a/src/components/ui/ScreenHeader.tsx b/src/components/ui/ScreenHeader.tsx
index 1eeb28dd..12cd54d6 100644
--- a/src/components/ui/ScreenHeader.tsx
+++ b/src/components/ui/ScreenHeader.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { AppLogo } from "./AppLogo";
type ScreenHeaderPlacement = "fixed" | "sticky" | "inline";
@@ -46,7 +46,7 @@ export function ScreenHeader({
className = "",
}: ScreenHeaderProps) {
const backControl = (
-
← {backLabel}
-
+
);
const content = (
diff --git a/src/components/ui/ScreenNav.tsx b/src/components/ui/ScreenNav.tsx
index 8a4d0a9a..76138dfe 100644
--- a/src/components/ui/ScreenNav.tsx
+++ b/src/components/ui/ScreenNav.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../navigation/AppLink";
import { HudHomeIcon } from "./HudIcons";
import {
screenBackLinkClassName,
@@ -46,17 +46,17 @@ export function ScreenNav({
return (
);
diff --git a/src/domain/device/changelog.ts b/src/domain/device/changelog.ts
index 5d337360..0303b9be 100644
--- a/src/domain/device/changelog.ts
+++ b/src/domain/device/changelog.ts
@@ -35,7 +35,7 @@ export const CHANGELOG: ChangelogEntry[] = [
{
title: "Improvements",
items: [
- "Admin map: mission-control layout on tablet and desktop with a collapsible side rail for diagnostics, sync, stats, map debug, log, chat, and moderation.",
+ "Motion: route changes show a loading overlay until the destination screen is ready, then play the existing slide transition.",
"Motion: unified sheet and panel drag physics, iOS-calibrated motion tokens, and expanded tap feedback with Vibration API fallback on web.",
"Motion: iOS-style route push/pop transitions, global edge-swipe back, and unified session exit so map teardown runs after the home transition.",
"Motion: retuned mobile sheets, wizard steps, and map panel snap to iOS-calibrated timing on the critical path.",
diff --git a/src/hooks/useAppNavigate.test.tsx b/src/hooks/useAppNavigate.test.tsx
index 560e1871..5ed4162e 100644
--- a/src/hooks/useAppNavigate.test.tsx
+++ b/src/hooks/useAppNavigate.test.tsx
@@ -1,28 +1,29 @@
import { renderHook, act } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useAppNavigate, resetAppNavigationStackForTests, useAppNavigationStack } from "./useAppNavigate";
-
-const navigateMock = vi.fn();
-
-vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual(
- "react-router-dom",
- );
- return {
- ...actual,
- useNavigate: () => navigateMock,
- };
-});
+import {
+ useAppNavigate,
+ resetAppNavigationStackForTests,
+ useAppNavigationStack,
+} from "./useAppNavigate";
+
+const beginTransitionMock = vi.fn(async () => undefined);
+
+vi.mock("../navigation/useRouteTransition", () => ({
+ useRouteTransition: () => ({
+ phase: "idle" as const,
+ beginTransition: beginTransitionMock,
+ reportScreenReady: vi.fn(),
+ }),
+}));
describe("useAppNavigate", () => {
beforeEach(() => {
- navigateMock.mockReset();
+ beginTransitionMock.mockReset();
resetAppNavigationStackForTests("/");
- delete document.documentElement.dataset.navDirection;
});
- it("sets forward nav direction and enables view transitions by default", () => {
+ it("delegates forward navigation to the transition coordinator", () => {
const { result } = renderHook(() => useAppNavigate(), {
wrapper: ({ children }) => {children},
});
@@ -31,13 +32,16 @@ describe("useAppNavigate", () => {
result.current("/create");
});
- expect(document.documentElement.dataset.navDirection).toBe("forward");
- expect(navigateMock).toHaveBeenCalledWith("/create", {
- viewTransition: true,
+ expect(beginTransitionMock).toHaveBeenCalledWith("/create", {
+ direction: "forward",
+ replace: undefined,
+ state: undefined,
+ preventScrollReset: undefined,
+ relative: undefined,
});
});
- it("uses neutral direction for replace navigations", () => {
+ it("uses replace direction for replace navigations", () => {
const { result } = renderHook(() => useAppNavigate(), {
wrapper: ({ children }) => {children},
});
@@ -46,14 +50,16 @@ describe("useAppNavigate", () => {
result.current("/", { replace: true });
});
- expect(document.documentElement.dataset.navDirection).toBe("neutral");
- expect(navigateMock).toHaveBeenCalledWith("/", {
+ expect(beginTransitionMock).toHaveBeenCalledWith("/", {
+ direction: "replace",
replace: true,
- viewTransition: true,
+ state: undefined,
+ preventScrollReset: undefined,
+ relative: undefined,
});
});
- it("uses back nav direction when requested", () => {
+ it("uses back direction when requested", () => {
const { result } = renderHook(() => useAppNavigate(), {
wrapper: ({ children }) => {children},
});
@@ -63,9 +69,12 @@ describe("useAppNavigate", () => {
result.current("/", { direction: "back" });
});
- expect(document.documentElement.dataset.navDirection).toBe("back");
- expect(navigateMock).toHaveBeenLastCalledWith("/", {
- viewTransition: true,
+ expect(beginTransitionMock).toHaveBeenLastCalledWith("/", {
+ direction: "back",
+ replace: undefined,
+ state: undefined,
+ preventScrollReset: undefined,
+ relative: undefined,
});
});
@@ -89,9 +98,8 @@ describe("useAppNavigate", () => {
stackResult.current.goBack();
});
- expect(navigateMock).toHaveBeenLastCalledWith("/", {
- viewTransition: true,
+ expect(beginTransitionMock).toHaveBeenLastCalledWith("/", {
+ direction: "back",
});
- expect(document.documentElement.dataset.navDirection).toBe("back");
});
});
diff --git a/src/hooks/useAppNavigate.ts b/src/hooks/useAppNavigate.ts
index 9c89b5f6..c3496472 100644
--- a/src/hooks/useAppNavigate.ts
+++ b/src/hooks/useAppNavigate.ts
@@ -1,6 +1,8 @@
import { useCallback } from "react";
-import { useNavigate, type NavigateOptions, type To } from "react-router-dom";
-import { useMotionProfile } from "./useMotionProfile";
+import { type NavigateOptions, type To } from "react-router-dom";
+import type { BeginTransitionOptions } from "../navigation/routeTransitionContextInstance";
+import { useRouteTransition } from "../navigation/useRouteTransition";
+import { resolveNavigatePath } from "../navigation/routePreloaders";
const RESET_PATHS = new Set(["/", "/map"]);
@@ -8,31 +10,22 @@ let navigationStack: string[] = [
typeof window !== "undefined" ? window.location.pathname : "/",
];
-function resolvePath(to: To): string {
- if (typeof to === "string") {
- return to.split("?")[0]?.split("#")[0] ?? "";
- }
-
- return to.pathname ?? "";
-}
-
/** @internal Test-only reset for navigation stack. */
export function resetAppNavigationStackForTests(path = "/"): void {
navigationStack = [path];
}
export function useAppNavigate() {
- const navigate = useNavigate();
- const { animate } = useMotionProfile();
+ const { beginTransition } = useRouteTransition();
return useCallback(
(
to: To,
options?: NavigateOptions & {
- direction?: "forward" | "back" | "replace";
+ direction?: BeginTransitionOptions["direction"];
},
) => {
- const path = resolvePath(to);
+ const path = resolveNavigatePath(to);
const direction =
options?.direction ?? (options?.replace ? "replace" : "forward");
@@ -44,25 +37,20 @@ export function useAppNavigate() {
navigationStack.push(path);
}
- const navDir =
- direction === "back" ? "back" : direction === "replace" ? "neutral" : "forward";
- document.documentElement.dataset.navDirection = navDir;
-
- navigate(to, {
+ void beginTransition(to, {
replace: options?.replace,
state: options?.state,
preventScrollReset: options?.preventScrollReset,
relative: options?.relative,
- viewTransition: animate,
+ direction,
});
},
- [navigate, animate],
+ [beginTransition],
);
}
export function useAppNavigationStack() {
- const navigate = useNavigate();
- const { animate } = useMotionProfile();
+ const { beginTransition } = useRouteTransition();
const canGoBack = useCallback(() => navigationStack.length > 1, []);
@@ -73,9 +61,9 @@ export function useAppNavigationStack() {
navigationStack.pop();
const destination = navigationStack[navigationStack.length - 1] ?? "/";
- document.documentElement.dataset.navDirection = "back";
- navigate(destination, { viewTransition: animate });
- }, [navigate, animate]);
+
+ void beginTransition(destination, { direction: "back" });
+ }, [beginTransition]);
return { canGoBack, goBack };
}
diff --git a/src/index.css b/src/index.css
index e44062ba..48ca4251 100644
--- a/src/index.css
+++ b/src/index.css
@@ -9,4 +9,5 @@
@import "./styles/home-entry.css";
@import "./styles/leaflet-overrides.css";
@import "./styles/motion.css";
+@import "./styles/route-transition.css";
@import "./styles/motion-utilities.css";
diff --git a/src/navigation/AppNavigate.tsx b/src/navigation/AppNavigate.tsx
new file mode 100644
index 00000000..e6d69851
--- /dev/null
+++ b/src/navigation/AppNavigate.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from "react";
+import { useNavigate, type NavigateProps } from "react-router-dom";
+import { useRouteTransition } from "./useRouteTransition";
+
+export function AppNavigate({ to, replace, state }: NavigateProps) {
+ const { phase, beginTransition } = useRouteTransition();
+ const navigate = useNavigate();
+ const ranRef = useRef(false);
+
+ useEffect(() => {
+ if (ranRef.current) {
+ return;
+ }
+ ranRef.current = true;
+
+ if (phase === "idle") {
+ void beginTransition(to, {
+ replace,
+ state,
+ direction: replace ? "replace" : "forward",
+ });
+ return;
+ }
+
+ navigate(to, { replace, state, viewTransition: false });
+ // Redirect once on mount; during an active gate, follow the chain in-place.
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only redirect
+ }, []);
+
+ return null;
+}
diff --git a/src/navigation/RouteReadinessSensor.tsx b/src/navigation/RouteReadinessSensor.tsx
new file mode 100644
index 00000000..d2c8047d
--- /dev/null
+++ b/src/navigation/RouteReadinessSensor.tsx
@@ -0,0 +1,16 @@
+import { useLayoutEffect } from "react";
+import { useLocation } from "react-router-dom";
+import { useRouteTransition } from "./useRouteTransition";
+import { useRouteScreenReady } from "./useRouteScreenReady";
+
+export function RouteReadinessSensor() {
+ const { pathname } = useLocation();
+ const screenReady = useRouteScreenReady(pathname);
+ const { reportScreenReady } = useRouteTransition();
+
+ useLayoutEffect(() => {
+ reportScreenReady(screenReady);
+ }, [screenReady, reportScreenReady]);
+
+ return null;
+}
diff --git a/src/navigation/RouteTransitionContext.tsx b/src/navigation/RouteTransitionContext.tsx
new file mode 100644
index 00000000..feea676d
--- /dev/null
+++ b/src/navigation/RouteTransitionContext.tsx
@@ -0,0 +1,170 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ useLocation,
+ useNavigate,
+ type To,
+} from "react-router-dom";
+import { useMotionProfile } from "../hooks/useMotionProfile";
+import { preloadRoute, resolveNavigatePath } from "./routePreloaders";
+import {
+ revealRouteTransition,
+ type NavRevealDirection,
+} from "./revealRouteTransition";
+import {
+ RouteTransitionContext,
+ type BeginTransitionOptions,
+ type RouteTransitionPhase,
+} from "./routeTransitionContextInstance";
+
+export type { BeginTransitionOptions, RouteTransitionPhase };
+
+const READY_POLL_MS = 16;
+const READY_TIMEOUT_MS = 15_000;
+
+function toRevealDirection(
+ direction: BeginTransitionOptions["direction"],
+): NavRevealDirection {
+ if (direction === "back") {
+ return "back";
+ }
+ if (direction === "replace") {
+ return "neutral";
+ }
+ return "forward";
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+}
+
+export function RouteTransitionProvider({ children }: { children: ReactNode }) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { animate } = useMotionProfile();
+ const [phase, setPhase] = useState("idle");
+
+ const phaseRef = useRef(phase);
+ const screenReadyRef = useRef(true);
+ const loadingTargetRef = useRef(null);
+ const pathnameRef = useRef(location.pathname);
+
+ useEffect(() => {
+ phaseRef.current = phase;
+ }, [phase]);
+
+ useEffect(() => {
+ pathnameRef.current = location.pathname;
+ }, [location.pathname]);
+
+ const reportScreenReady = useCallback((ready: boolean) => {
+ screenReadyRef.current = ready;
+ }, []);
+
+ const waitForScreenReady = useCallback(async (): Promise => {
+ const deadline = Date.now() + READY_TIMEOUT_MS;
+ let lastPathname = pathnameRef.current;
+
+ while (Date.now() < deadline) {
+ const currentPathname = pathnameRef.current;
+
+ if (currentPathname !== lastPathname) {
+ lastPathname = currentPathname;
+ screenReadyRef.current = false;
+ }
+
+ if (screenReadyRef.current) {
+ return;
+ }
+
+ await delay(READY_POLL_MS);
+ }
+ }, []);
+
+ const beginTransition = useCallback(
+ async (to: To, options?: BeginTransitionOptions) => {
+ const targetPath = resolveNavigatePath(to);
+ const navigateOptions = {
+ replace: options?.replace,
+ state: options?.state,
+ preventScrollReset: options?.preventScrollReset,
+ relative: options?.relative,
+ viewTransition: false as const,
+ };
+
+ if (
+ phaseRef.current !== "idle" &&
+ loadingTargetRef.current === targetPath
+ ) {
+ return;
+ }
+
+ if (phaseRef.current !== "idle") {
+ try {
+ await preloadRoute(targetPath);
+ } catch {
+ // Warm-up only; the rendered lazy route retries chunk failures itself.
+ }
+
+ navigate(to, navigateOptions);
+ return;
+ }
+
+ loadingTargetRef.current = targetPath;
+ screenReadyRef.current = false;
+ phaseRef.current = "loading";
+ setPhase("loading");
+
+ try {
+ try {
+ await preloadRoute(targetPath);
+ } catch {
+ // Warm-up only; the rendered lazy route retries chunk failures itself.
+ }
+
+ navigate(to, navigateOptions);
+
+ await waitForScreenReady();
+
+ phaseRef.current = "revealing";
+ setPhase("revealing");
+ try {
+ await revealRouteTransition(
+ toRevealDirection(options?.direction),
+ animate,
+ );
+ } catch {
+ // Navigation succeeded; a failed reveal should not block the route.
+ }
+ } finally {
+ loadingTargetRef.current = null;
+ phaseRef.current = "idle";
+ setPhase("idle");
+ }
+ },
+ [animate, navigate, waitForScreenReady],
+ );
+
+ const value = useMemo(
+ () => ({
+ phase,
+ beginTransition,
+ reportScreenReady,
+ }),
+ [phase, beginTransition, reportScreenReady],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/navigation/RouteTransitionOverlay.tsx b/src/navigation/RouteTransitionOverlay.tsx
new file mode 100644
index 00000000..a6f58d16
--- /dev/null
+++ b/src/navigation/RouteTransitionOverlay.tsx
@@ -0,0 +1,26 @@
+import { LoadingSpinnerRing } from "../components/ui/LoadingSpinner";
+import { useRouteTransition } from "./useRouteTransition";
+
+export function RouteTransitionOverlay() {
+ const { phase } = useRouteTransition();
+
+ if (phase !== "loading") {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/navigation/revealRouteTransition.ts b/src/navigation/revealRouteTransition.ts
new file mode 100644
index 00000000..f7099581
--- /dev/null
+++ b/src/navigation/revealRouteTransition.ts
@@ -0,0 +1,16 @@
+export type NavRevealDirection = "forward" | "back" | "neutral";
+
+export function revealRouteTransition(
+ direction: NavRevealDirection,
+ animate: boolean,
+): Promise {
+ document.documentElement.dataset.navDirection = direction;
+
+ if (!animate || typeof document.startViewTransition !== "function") {
+ return Promise.resolve();
+ }
+
+ return document.startViewTransition(() => {
+ // Destination is already mounted; capture the reveal frame.
+ }).finished.catch(() => undefined);
+}
diff --git a/src/navigation/routePreloaders.ts b/src/navigation/routePreloaders.ts
new file mode 100644
index 00000000..2b961bdb
--- /dev/null
+++ b/src/navigation/routePreloaders.ts
@@ -0,0 +1,74 @@
+import type { To } from "react-router-dom";
+import { lazyWithChunkRetry } from "../domain/device/lazyWithChunkRetry";
+
+export const importMapScreen = () =>
+ import("../routes/MapScreen").then((m) => ({ default: m.MapScreen }));
+
+export const importCreateSession = () =>
+ import("../routes/CreateSession").then((m) => ({ default: m.CreateSession }));
+
+export const importGamePresetList = () =>
+ import("../routes/GamePresets").then((m) => ({ default: m.GamePresetList }));
+
+export const importGamePresetEditor = () =>
+ import("../routes/GamePresets").then((m) => ({ default: m.GamePresetEditor }));
+
+export const importTutorial = () =>
+ import("../routes/Tutorial").then((m) => ({ default: m.Tutorial }));
+
+export const routeImporter = {
+ importMapScreen,
+ importCreateSession,
+ importGamePresetList,
+ importGamePresetEditor,
+ importTutorial,
+};
+
+export const MapScreenLazy = lazyWithChunkRetry(importMapScreen);
+export const CreateSessionLazy = lazyWithChunkRetry(importCreateSession);
+export const GamePresetListLazy = lazyWithChunkRetry(importGamePresetList);
+export const GamePresetEditorLazy = lazyWithChunkRetry(importGamePresetEditor);
+export const TutorialLazy = lazyWithChunkRetry(importTutorial);
+
+const PRESET_EDIT_PATH_RE = /^\/presets\/[^/]+\/edit$/;
+
+const LAZY_ROUTE_LOADERS: Record<
+ string,
+ keyof typeof routeImporter
+> = {
+ "/map": "importMapScreen",
+ "/create": "importCreateSession",
+ "/tutorial": "importTutorial",
+ "/presets": "importGamePresetList",
+ "/presets/new": "importGamePresetEditor",
+ "/presets/:id/edit": "importGamePresetEditor",
+};
+
+export function normalizeRoutePath(path: string): string {
+ const base = path.split("?")[0]?.split("#")[0] ?? "/";
+
+ if (PRESET_EDIT_PATH_RE.test(base)) {
+ return "/presets/:id/edit";
+ }
+
+ return base || "/";
+}
+
+export function resolveNavigatePath(to: To): string {
+ if (typeof to === "string") {
+ return normalizeRoutePath(to);
+ }
+
+ return normalizeRoutePath(to.pathname ?? "/");
+}
+
+export function isLazyRoute(path: string): boolean {
+ return normalizeRoutePath(path) in LAZY_ROUTE_LOADERS;
+}
+
+export async function preloadRoute(path: string): Promise {
+ const loaderKey = LAZY_ROUTE_LOADERS[normalizeRoutePath(path)];
+ if (loaderKey) {
+ await routeImporter[loaderKey]();
+ }
+}
diff --git a/src/navigation/routeTransition.test.ts b/src/navigation/routeTransition.test.ts
new file mode 100644
index 00000000..7fcad2d7
--- /dev/null
+++ b/src/navigation/routeTransition.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from "vitest";
+import * as routePreloaders from "./routePreloaders";
+import {
+ isLazyRoute,
+ normalizeRoutePath,
+ preloadRoute,
+} from "./routePreloaders";
+import { routeReadinessKind } from "./useRouteScreenReady";
+
+describe("normalizeRoutePath", () => {
+ it("strips query strings and hashes", () => {
+ expect(normalizeRoutePath("/map?foo=bar")).toBe("/map");
+ expect(normalizeRoutePath("/create#step-2")).toBe("/create");
+ });
+
+ it("normalizes preset edit paths", () => {
+ expect(normalizeRoutePath("/presets/abc123/edit")).toBe(
+ "/presets/:id/edit",
+ );
+ expect(normalizeRoutePath("/presets/new")).toBe("/presets/new");
+ });
+
+ it("defaults empty paths to root", () => {
+ expect(normalizeRoutePath("")).toBe("/");
+ });
+});
+
+describe("isLazyRoute", () => {
+ it("marks known lazy routes", () => {
+ expect(isLazyRoute("/map")).toBe(true);
+ expect(isLazyRoute("/create")).toBe(true);
+ expect(isLazyRoute("/tutorial")).toBe(true);
+ expect(isLazyRoute("/presets")).toBe(true);
+ expect(isLazyRoute("/presets/new")).toBe(true);
+ expect(isLazyRoute("/presets/foo/edit")).toBe(true);
+ });
+
+ it("marks eager routes as not lazy", () => {
+ expect(isLazyRoute("/")).toBe(false);
+ expect(isLazyRoute("/join")).toBe(false);
+ expect(isLazyRoute("/admin")).toBe(false);
+ expect(isLazyRoute("/premium")).toBe(false);
+ });
+});
+
+describe("preloadRoute", () => {
+ it("resolves immediately for eager routes", async () => {
+ await expect(preloadRoute("/join")).resolves.toBeUndefined();
+ });
+
+ it("loads lazy route modules without throwing", async () => {
+ await expect(preloadRoute("/tutorial")).resolves.toBeUndefined();
+ });
+
+ it("invokes the lazy loader for /map and query-bearing paths", async () => {
+ const mapLoader = vi.spyOn(routePreloaders.routeImporter, "importMapScreen");
+
+ await preloadRoute("/map");
+ expect(mapLoader).toHaveBeenCalledTimes(1);
+
+ mapLoader.mockClear();
+ await preloadRoute("/map?session=abc");
+ expect(mapLoader).toHaveBeenCalledTimes(1);
+
+ mapLoader.mockRestore();
+ });
+});
+
+describe("routeReadinessKind", () => {
+ it("maps primary screens to readiness signals", () => {
+ expect(routeReadinessKind("/")).toBe("auth-bootstrap");
+ expect(routeReadinessKind("/map")).toBe("play-area");
+ expect(routeReadinessKind("/admin")).toBe("admin-auth");
+ expect(routeReadinessKind("/premium")).toBe("premium");
+ });
+
+ it("uses layout readiness for secondary routes", () => {
+ expect(routeReadinessKind("/join")).toBe("layout");
+ expect(routeReadinessKind("/create")).toBe("layout");
+ expect(routeReadinessKind("/tutorial")).toBe("layout");
+ expect(routeReadinessKind("/presets")).toBe("layout");
+ expect(routeReadinessKind("/feedback")).toBe("layout");
+ });
+});
diff --git a/src/navigation/routeTransitionContextInstance.ts b/src/navigation/routeTransitionContextInstance.ts
new file mode 100644
index 00000000..582e67f9
--- /dev/null
+++ b/src/navigation/routeTransitionContextInstance.ts
@@ -0,0 +1,17 @@
+import { createContext } from "react";
+import type { NavigateOptions, To } from "react-router-dom";
+
+export type RouteTransitionPhase = "idle" | "loading" | "revealing";
+
+export type BeginTransitionOptions = NavigateOptions & {
+ direction?: "forward" | "back" | "replace";
+};
+
+export interface RouteTransitionContextValue {
+ phase: RouteTransitionPhase;
+ beginTransition: (to: To, options?: BeginTransitionOptions) => Promise;
+ reportScreenReady: (ready: boolean) => void;
+}
+
+export const RouteTransitionContext =
+ createContext(null);
diff --git a/src/navigation/useRouteScreenReady.ts b/src/navigation/useRouteScreenReady.ts
new file mode 100644
index 00000000..6c51725c
--- /dev/null
+++ b/src/navigation/useRouteScreenReady.ts
@@ -0,0 +1,48 @@
+import { usePremiumEntitlements } from "../hooks/billing/usePremiumEntitlements";
+import { usePermanentAuthUser } from "../hooks/billing/usePermanentAuthUser";
+import { useAuthBootstrapReady } from "../hooks/useAuthBootstrapReady";
+import { useResolvedSessionRules } from "../hooks/session/useResolvedSessionRules";
+import { useSessionStore } from "../state/sessionStore";
+
+export type RouteReadinessKind =
+ | "auth-bootstrap"
+ | "play-area"
+ | "admin-auth"
+ | "premium"
+ | "layout";
+
+export function routeReadinessKind(pathname: string): RouteReadinessKind {
+ switch (pathname) {
+ case "/":
+ return "auth-bootstrap";
+ case "/map":
+ return "play-area";
+ case "/admin":
+ return "admin-auth";
+ case "/premium":
+ return "premium";
+ default:
+ return "layout";
+ }
+}
+
+export function useRouteScreenReady(pathname: string): boolean {
+ const authBootstrapReady = useAuthBootstrapReady();
+ const session = useSessionStore((state) => state.session);
+ const { playAreaReady } = useResolvedSessionRules(session);
+ const { authReady } = usePermanentAuthUser();
+ const { loading: premiumLoading } = usePremiumEntitlements();
+
+ switch (routeReadinessKind(pathname)) {
+ case "auth-bootstrap":
+ return authBootstrapReady;
+ case "play-area":
+ return playAreaReady;
+ case "admin-auth":
+ return authReady;
+ case "premium":
+ return !premiumLoading;
+ case "layout":
+ return true;
+ }
+}
diff --git a/src/navigation/useRouteTransition.ts b/src/navigation/useRouteTransition.ts
new file mode 100644
index 00000000..e4f18397
--- /dev/null
+++ b/src/navigation/useRouteTransition.ts
@@ -0,0 +1,13 @@
+import { useContext } from "react";
+import {
+ RouteTransitionContext,
+ type RouteTransitionContextValue,
+} from "./routeTransitionContextInstance";
+
+export function useRouteTransition(): RouteTransitionContextValue {
+ const context = useContext(RouteTransitionContext);
+ if (!context) {
+ throw new Error("useRouteTransition must be used within RouteTransitionProvider");
+ }
+ return context;
+}
diff --git a/src/routes/AdminMapScreen.tsx b/src/routes/AdminMapScreen.tsx
index a6c4f0d5..67577ad1 100644
--- a/src/routes/AdminMapScreen.tsx
+++ b/src/routes/AdminMapScreen.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { Navigate } from "react-router-dom";
+import { AppNavigate } from "../navigation/AppNavigate";
import { GameAreaMask } from "../components/map/GameAreaMask";
import { MapView } from "../components/map/MapView";
import { MapViewportTracker } from "../components/map/MapViewportTracker";
@@ -109,11 +109,11 @@ export function AdminMapScreen({
}, [isWide, railCollapsed]);
if (!controller.session) {
- return ;
+ return ;
}
if (controller.myRole !== "admin") {
- return ;
+ return ;
}
if (!controller.playAreaReady) {
diff --git a/src/routes/AdminPanel.tsx b/src/routes/AdminPanel.tsx
index b1db52d1..a0cb5abf 100644
--- a/src/routes/AdminPanel.tsx
+++ b/src/routes/AdminPanel.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../components/navigation/AppLink";
import { useCallback, useMemo, useState } from "react";
import { signOut } from "firebase/auth";
import { PremiumSignInGate } from "../components/billing/PremiumSignInGate";
@@ -186,9 +186,9 @@ export function AdminPanel() {
>
Sign out
-
+
Back home
-
+
diff --git a/src/routes/CreateSession.test.tsx b/src/routes/CreateSession.test.tsx
index 150ae213..2b9c6421 100644
--- a/src/routes/CreateSession.test.tsx
+++ b/src/routes/CreateSession.test.tsx
@@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { useEffect, useRef } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { CreateSession } from "./CreateSession";
import { renderWithRouter } from "../test/renderWithRouter";
import type { GeocodedPlace } from "../services/geo/geocoding";
@@ -100,13 +100,12 @@ vi.mock("../services/geo/seaLevelProgressive", () => ({
}));
const navigate = vi.fn();
-vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual("react-router-dom");
- return {
- ...actual,
- useNavigate: () => navigate,
- };
+beforeEach(() => {
+ navigate.mockReset();
});
+vi.mock("../hooks/useAppNavigate", () => ({
+ useAppNavigate: () => navigate,
+}));
describe("CreateSession", () => {
it("renders shape picker and fullscreen framing entry point", () => {
@@ -125,7 +124,7 @@ describe("CreateSession", () => {
fireEvent.click(screen.getByRole("button", { name: "Confirm game area" }));
await waitFor(() => {
- expect(navigate).toHaveBeenCalledWith("/map", { viewTransition: true });
+ expect(navigate).toHaveBeenCalledWith("/map");
});
});
@@ -143,7 +142,7 @@ describe("CreateSession", () => {
fireEvent.click(screen.getByRole("button", { name: "Confirm game area" }));
await waitFor(() => {
- expect(navigate).toHaveBeenCalledWith("/map", { viewTransition: true });
+ expect(navigate).toHaveBeenCalledWith("/map");
});
});
diff --git a/src/routes/GamePresets.test.tsx b/src/routes/GamePresets.test.tsx
index 873991e4..e47b34e3 100644
--- a/src/routes/GamePresets.test.tsx
+++ b/src/routes/GamePresets.test.tsx
@@ -8,13 +8,9 @@ import { mergeBundledPresets, BUNDLED_GAME_PRESET_DEFINITIONS } from "../domain/
const navigate = vi.fn();
-vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual("react-router-dom");
- return {
- ...actual,
- useNavigate: () => navigate,
- };
-});
+vi.mock("../hooks/useAppNavigate", () => ({
+ useAppNavigate: () => navigate,
+}));
vi.mock("../components/map/MapView", () => ({
MapView: () => ,
@@ -217,6 +213,6 @@ describe("GamePresetEditor", () => {
.getState()
.presets.find((preset) => preset.name === "Dublin medium")?.name,
).toBe("Dublin medium");
- expect(navigate).toHaveBeenCalledWith("/presets", { viewTransition: true });
+ expect(navigate).toHaveBeenCalledWith("/presets");
});
});
diff --git a/src/routes/GamePresets.tsx b/src/routes/GamePresets.tsx
index 84185fc4..489b34b6 100644
--- a/src/routes/GamePresets.tsx
+++ b/src/routes/GamePresets.tsx
@@ -1,5 +1,6 @@
import { useCallback, useId, useMemo, useState } from "react";
-import { Link, useParams } from "react-router-dom";
+import { AppLink } from "../components/navigation/AppLink";
+import { useParams } from "react-router-dom";
import { useAppNavigate } from "../hooks/useAppNavigate";
import type { LatLngBoundsExpression } from "leaflet";
import {
@@ -311,9 +312,9 @@ export function GamePresetEditor() {
Save preset
{existing && !needsMigrationReview ? (
-
+
Host
-
+
) : null}
{existing ? (
) : null}
-
+
Cancel
-
+
diff --git a/src/routes/HiderMapScreen.tsx b/src/routes/HiderMapScreen.tsx
index 51b58166..2d713090 100644
--- a/src/routes/HiderMapScreen.tsx
+++ b/src/routes/HiderMapScreen.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
-import { Navigate } from "react-router-dom";
+import { AppNavigate } from "../navigation/AppNavigate";
import { Polygon } from "react-leaflet";
import { AnnotationLayer } from "../components/map/AnnotationLayer";
import { GameAreaMask } from "../components/map/GameAreaMask";
@@ -457,7 +457,7 @@ export function HiderMapScreen() {
);
if (!session || !gameArea) {
- return ;
+ return ;
}
const mapFocusBounds = gameAreaToBoundsExpression(gameArea);
diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx
index bdfb3d3f..df624e2d 100644
--- a/src/routes/Home.tsx
+++ b/src/routes/Home.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../components/navigation/AppLink";
import { useState } from "react";
import { AppLogo } from "../components/ui/AppLogo";
import { EntryScreenLayout } from "../components/ui/EntryScreenLayout";
@@ -23,6 +23,7 @@ import { isFirestorePermissionDenied } from "../services/firestore/firestoreAnno
import { useSessionExit } from "../hooks/session/useSessionExit";
import { setPremiumApiContext } from "../services/core/premiumApiContext";
import { useAppNavigate } from "../hooks/useAppNavigate";
+import { useRouteTransition } from "../navigation/useRouteTransition";
import { usePremiumEntitlements } from "../hooks/billing/usePremiumEntitlements";
import { resolveHomePremiumButtonDisplay } from "../domain/billing/premiumProducts";
import { useAuthBootstrapReady } from "../hooks/useAuthBootstrapReady";
@@ -44,10 +45,15 @@ export function Home() {
const { user: permanentUser } = usePermanentAuthUser();
const showAdminEntry = isAdminUser(permanentUser);
const authBootstrapReady = useAuthBootstrapReady();
+ const { phase: routeTransitionPhase } = useRouteTransition();
const premiumButton = resolveHomePremiumButtonDisplay(premiumEntitlements);
- if (isFirebaseConfigured() && !authBootstrapReady) {
+ if (
+ isFirebaseConfigured() &&
+ !authBootstrapReady &&
+ routeTransitionPhase === "idle"
+ ) {
return (
{showAdminEntry ? (
-
-
+
) : null}
-
-
+
setChangelogOpen(true)}
@@ -246,7 +252,7 @@ export function Home() {
) : null}
-
Create session
Host a game
-
-
+
+
Join session
Enter 4-letter code
-
-
+
Custom game
Saved templates
-
+
{isFirebaseConfigured() ? (
-
{premiumButton.detailLabel}
-
+
) : null}
{continueError ?
{continueError} : null}
diff --git a/src/routes/MapScreen.test.tsx b/src/routes/MapScreen.test.tsx
index d9edbfb0..1ef87609 100644
--- a/src/routes/MapScreen.test.tsx
+++ b/src/routes/MapScreen.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Route, Routes } from "react-router-dom";
import { MapScreen } from "./MapScreen";
@@ -100,7 +100,7 @@ vi.mock("../services/core/firebase", () => ({
}));
describe("MapScreen", () => {
- it("redirects to create when no session game area exists", () => {
+ it("redirects to create when no session game area exists", async () => {
useSessionStore.getState().setSession(
createTestSession({ gameArea: undefined }),
);
@@ -113,7 +113,9 @@ describe("MapScreen", () => {
{ route: "/map", resetStores: false },
);
- expect(screen.getByText("Create session landing")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText("Create session landing")).toBeInTheDocument();
+ });
});
it("renders the tool dock for an active session", () => {
diff --git a/src/routes/MapScreen.tsx b/src/routes/MapScreen.tsx
index 31858239..86f9f8b8 100644
--- a/src/routes/MapScreen.tsx
+++ b/src/routes/MapScreen.tsx
@@ -1,5 +1,5 @@
import { useEffect, type ReactNode } from "react";
-import { Navigate } from "react-router-dom";
+import { AppNavigate } from "../navigation/AppNavigate";
import { HiderMapScreen } from "./HiderMapScreen";
import { AdminMapScreen } from "./AdminMapScreen";
import { ObserverMapScreen } from "./ObserverMapScreen";
@@ -25,7 +25,7 @@ export function MapScreen() {
useEffect(() => () => teardownSessionUiState(), []);
if (!session || !session.gameArea) {
- return ;
+ return ;
}
if (myRole === "hider") {
diff --git a/src/routes/ObserverMapScreen.tsx b/src/routes/ObserverMapScreen.tsx
index 55d66cbc..206e7612 100644
--- a/src/routes/ObserverMapScreen.tsx
+++ b/src/routes/ObserverMapScreen.tsx
@@ -1,5 +1,5 @@
import { useCallback } from "react";
-import { Navigate } from "react-router-dom";
+import { AppNavigate } from "../navigation/AppNavigate";
import { GameAreaMask } from "../components/map/GameAreaMask";
import { MapView } from "../components/map/MapView";
import { MapViewportTracker } from "../components/map/MapViewportTracker";
@@ -41,12 +41,12 @@ export function ObserverMapScreen() {
if (!controller.session) {
return (
-
+
);
}
if (controller.myRole !== "observer" && controller.myRole !== "admin") {
- return ;
+ return ;
}
if (!controller.playAreaReady) {
diff --git a/src/routes/create-session/PremiumGateSection.tsx b/src/routes/create-session/PremiumGateSection.tsx
index ac2cda6c..0cb76b93 100644
--- a/src/routes/create-session/PremiumGateSection.tsx
+++ b/src/routes/create-session/PremiumGateSection.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../../components/navigation/AppLink";
import { PremiumSignInGate } from "../../components/billing/PremiumSignInGate";
export interface PremiumGateSectionProps {
@@ -44,12 +44,12 @@ export function PremiumGateSection({
Buy a session pack or subscription to host premium games.
-
View premium options
-
+
onAccessCodeExpandedChange(!accessCodeExpanded)}
diff --git a/src/routes/observer-map-screen/ObserverMapScreenChrome.tsx b/src/routes/observer-map-screen/ObserverMapScreenChrome.tsx
index b2587ba9..ac157722 100644
--- a/src/routes/observer-map-screen/ObserverMapScreenChrome.tsx
+++ b/src/routes/observer-map-screen/ObserverMapScreenChrome.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { AppLink } from "../../components/navigation/AppLink";
import { SessionTimerLabel } from "../../components/session/SessionTimerLabel";
import { SegmentControl } from "../../components/ui/SegmentControl";
import { HudHomeIcon } from "../../components/ui/HudIcons";
@@ -100,12 +100,12 @@ export function ObserverMapScreenChrome({
Chat
{myRole === "admin" ? (
-
Admin
-
+
) : null}
diff --git a/src/styles/route-transition.css b/src/styles/route-transition.css
new file mode 100644
index 00000000..38e779a5
--- /dev/null
+++ b/src/styles/route-transition.css
@@ -0,0 +1,78 @@
+.route-transition-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-modal);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-surface-deep);
+ animation: route-transition-overlay-enter var(--motion-fast, 200ms)
+ var(--ease-out-quint, cubic-bezier(0.22, 1, 0.36, 1)) both;
+}
+
+.route-transition-overlay-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.route-transition-overlay-label {
+ font-family: var(--font-display, inherit);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--color-ink-secondary, currentColor);
+}
+
+@keyframes route-transition-overlay-enter {
+ from {
+ opacity: 0;
+ transform: scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes route-transition-overlay-exit {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.98);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .route-transition-overlay {
+ animation: route-transition-overlay-fade-in var(--motion-fast, 200ms) ease
+ both;
+ }
+
+ .route-transition-overlay .loading-spinner {
+ animation: none !important;
+ }
+}
+
+html[data-motion="reduce"] .route-transition-overlay {
+ animation: route-transition-overlay-fade-in var(--motion-fast, 200ms) ease
+ both;
+}
+
+html[data-motion="reduce"] .route-transition-overlay .loading-spinner {
+ animation: none !important;
+}
+
+@keyframes route-transition-overlay-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/src/test/RouteTransitionTestProvider.tsx b/src/test/RouteTransitionTestProvider.tsx
new file mode 100644
index 00000000..bcffa068
--- /dev/null
+++ b/src/test/RouteTransitionTestProvider.tsx
@@ -0,0 +1,51 @@
+import { useCallback, useMemo, useRef, type ReactNode } from "react";
+import { useNavigate, type To } from "react-router-dom";
+import type { BeginTransitionOptions, RouteTransitionPhase } from "../navigation/routeTransitionContextInstance";
+import { RouteTransitionContext } from "../navigation/routeTransitionContextInstance";
+import { preloadRoute, resolveNavigatePath } from "../navigation/routePreloaders";
+import { revealRouteTransition } from "../navigation/revealRouteTransition";
+
+/** Fast path for unit tests: skip readiness polling and motion. */
+export function RouteTransitionTestProvider({ children }: { children: ReactNode }) {
+ const navigate = useNavigate();
+ const screenReadyRef = useRef(true);
+
+ const beginTransition = useCallback(
+ async (to: To, options?: BeginTransitionOptions) => {
+ screenReadyRef.current = true;
+ await preloadRoute(resolveNavigatePath(to));
+ navigate(to, {
+ replace: options?.replace,
+ state: options?.state,
+ preventScrollReset: options?.preventScrollReset,
+ relative: options?.relative,
+ viewTransition: false,
+ });
+ const direction =
+ options?.direction === "back"
+ ? "back"
+ : options?.direction === "replace"
+ ? "neutral"
+ : "forward";
+ await revealRouteTransition(direction, false);
+ },
+ [navigate],
+ );
+
+ const value = useMemo(
+ () => ({
+ phase: "idle" as RouteTransitionPhase,
+ beginTransition,
+ reportScreenReady: (ready: boolean) => {
+ screenReadyRef.current = ready;
+ },
+ }),
+ [beginTransition],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/test/renderWithRouter.tsx b/src/test/renderWithRouter.tsx
index 74f77d1c..ca8fd1f9 100644
--- a/src/test/renderWithRouter.tsx
+++ b/src/test/renderWithRouter.tsx
@@ -1,6 +1,7 @@
import { render, type RenderOptions } from "@testing-library/react";
import { type ReactElement, type ReactNode } from "react";
import { MemoryRouter, type MemoryRouterProps } from "react-router-dom";
+import { RouteTransitionTestProvider } from "./RouteTransitionTestProvider";
import { resetAllStores } from "./helpers/storeReset";
interface RenderWithRouterOptions extends Omit {
@@ -27,7 +28,7 @@ export function renderWithRouter(
function Wrapper({ children }: { children: ReactNode }) {
return (
- {children}
+ {children}
);
}