# Page Navigation Documentation for the Tour Builder Platform's page navigation system using element-based navigation stored in `ui_schema_json`. ## Overview The platform uses a simplified navigation system where navigation configuration is stored directly in `tour_pages.ui_schema_json` as part of element definitions. This approach: - Eliminates ID remapping issues when publishing between environments - Uses page slugs instead of UUIDs for cross-environment consistency - Stores transition video URLs directly on navigation elements ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Page Navigation Architecture │ │ │ │ ┌────────────────────────────────────────────────────────────────────────┐ │ │ │ Navigation Source │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ │ tour_pages.ui_schema_json │ │ │ │ │ │ │ │ │ │ │ │ elements: [{ │ │ │ │ │ │ type: "navigation_next", │ │ │ │ │ │ targetPageSlug: "page-2", │ │ │ │ │ │ transitionVideoUrl: "assets/.../video.mp4", │ │ │ │ │ │ ... │ │ │ │ │ │ }] │ │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ ┌───────────────────────────────┐ │ │ │ │ │ Navigation Resolution │ │ │ │ │ │ │ │ │ │ │ │ • Resolve slug to page │ │ │ │ │ │ • Determine direction │ │ │ │ │ │ • Get preloaded blob URL │ │ │ │ │ └───────────────┬───────────────┘ │ │ │ │ │ │ │ │ │ ┌───────────────┴───────────────┐ │ │ │ │ ▼ ▼ │ │ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ │ │ With Transition │ │ Direct Navigate │ │ │ │ │ │ │ │ │ │ │ │ │ │ Play video → │ │ Switch page → │ │ │ │ │ │ Switch page │ │ Update history │ │ │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Data Model ### Navigation in ui_schema_json Navigation is configured directly within element objects in the `tour_pages.ui_schema_json` field: ```typescript // Element with navigation configuration { id: "element-uuid", type: "navigation_next", // or "navigation_prev" label: "Next Page Button", // Position xPercent: 90, yPercent: 85, // Navigation Configuration navigationTargetMode: "target_page", // "target_page" or "external_url" targetPageSlug: "page-2", // Slug-based navigation (consistent across environments) externalUrl: undefined, // Used when navigationTargetMode is "external_url" transitionVideoUrl: "assets/transitions/fade.mp4", transitionDurationSec: 0.7, transitionReverseMode: "auto_reverse", // or "separate_video" reverseVideoUrl: undefined, // Only used with "separate_video" mode // Element Styling iconUrl: "assets/icons/arrow-right.png", // ... extends ElementStyleProperties } ``` ### Key Fields | Field | Type | Description | |-------|------|-------------| | `type` | string | `navigation_next` or `navigation_prev` determines direction | | `navigationTargetMode` | `'target_page'` \| `'external_url'` | Destination mode for forward navigation buttons. Defaults to `target_page` | | `targetPageSlug` | string | Target page slug (NOT UUID) for cross-environment consistency | | `externalUrl` | string | External URL opened in a new tab when `navigationTargetMode` is `external_url` | | `transitionVideoUrl` | string | URL to transition video (optional) | | `transitionDurationSec` | number | Duration in seconds (optional) | | `transitionReverseMode` | `'auto_reverse'` \| `'separate_video'` | Reverse playback mode (replaces deprecated `supportsReverse`) | | `reverseVideoUrl` | string | Separate video URL for reverse playback (when mode is `separate_video`) | ### Why Slugs Instead of UUIDs Previous versions used `targetPageId` (UUID) which caused issues: - When pages are copied between environments (dev → stage → production), UUIDs change - References to old UUIDs became invalid after publishing The slug-based approach solves this: - Slugs are unique within project+environment - Slugs remain identical across environments (same page has same slug in dev/stage/prod) - No ID remapping needed during publish ## Navigation Types ### Forward Navigation (`navigation_next`) Navigates to a specified target page with optional transition. ```typescript { type: "navigation_next", navigationTargetMode: "target_page", targetPageSlug: "gallery", transitionVideoUrl: "assets/transitions/zoom-in.mp4", transitionReverseMode: "auto_reverse" } ``` **Behavior:** - Target page resolved by slug - Transition video plays forward if specified - Page added to navigation history ### External URL Navigation (`navigation_next`) Forward navigation buttons can open an external URL instead of targeting a tour page: ```typescript { type: "navigation_next", navType: "forward", navigationTargetMode: "external_url", externalUrl: "https://example.com" } ``` **Behavior:** - The button is treated as forward navigation - Target page selection is disabled and `targetPageSlug` / `targetPageId` are cleared - The URL opens in a new tab with `noopener,noreferrer` - If the URL omits `http://` or `https://`, the runtime opens it with an `https://` prefix - External URL buttons are not included in internal page-link extraction, neighbor preloading, or reversed transition video generation ### Back Navigation (`navigation_prev`) Returns to previous page with optional reverse transition. ```typescript { type: "navigation_prev", targetPageSlug: "home", // Optional - can use history transitionVideoUrl: "assets/transitions/zoom-in.mp4", transitionReverseMode: "auto_reverse" // Plays in reverse for back nav } ``` **Alternative with separate reverse video:** ```typescript { type: "navigation_prev", targetPageSlug: "home", transitionVideoUrl: "assets/transitions/zoom-in.mp4", transitionReverseMode: "separate_video", reverseVideoUrl: "assets/transitions/zoom-out.mp4" } ``` **Behavior:** - Target page optional (uses page history) - If `transitionReverseMode = 'auto_reverse'`, video plays in reverse - If `transitionReverseMode = 'separate_video'`, uses `reverseVideoUrl` instead - Page popped from navigation history ## Navigation Flow ### Runtime Resolution **Files:** - `frontend/src/components/RuntimePresentation.tsx` - `frontend/src/lib/navigationHelpers.ts` Navigation uses `resolveNavigationTarget` helper and `usePageSwitch` hook: ```typescript import { resolveNavigationTarget, isTransitionBlocking } from '../lib/navigationHelpers'; // Extract navigation links from pages const { pageLinks, preloadElements } = extractPageLinksAndElements(pages); // Initialize preload orchestrator const preloadOrchestrator = usePreloadOrchestrator({ pages, pageLinks, elements: preloadElements, currentPageId: selectedPageId, enabled: !isLoading, }); // Initialize page switch with preload cache const pageSwitch = usePageSwitch({ preloadCache: { getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, preloadedUrls: preloadOrchestrator.preloadedUrls, }, }); // Handle navigation element click const handleElementClick = useCallback((element) => { // Block navigation during active transitions if (isTransitionBlocking(transitionPhase, isBuffering)) { return; } // Resolve target page (supports both slug and legacy ID) const navTarget = resolveNavigationTarget(element, pages); if (!navTarget) return; // Navigate with transition if configured if (element.transitionVideoUrl) { // Start transition playback, then switch page on completion startTransition({ videoUrl: element.transitionVideoUrl, storageKey: element.transitionVideoUrl, // Raw path for cache lookup reverseMode: navTarget.isBack ? getReverseMode(element) : 'none', reverseVideoUrl: element.reverseVideoUrl, targetPageId: navTarget.pageId, }); } else { // Direct navigation (no transition) pageSwitch.switchToPage(navTarget.page, () => { setSelectedPageId(navTarget.pageId); }); } }, [pages, pageSwitch, transitionPhase, isBuffering]); ``` **Navigation Target Resolution (`navigationHelpers.ts`):** ```typescript // Supports both targetPageSlug (preferred) and targetPageId (legacy) export const resolveNavigationTarget = (element, pages) => { let targetPage; if (element.targetPageSlug) { targetPage = pages.find(p => p.slug === element.targetPageSlug); } else if (element.targetPageId) { targetPage = pages.find(p => p.id === element.targetPageId); } if (!targetPage) return null; const isBack = element.navType === 'back' || element.type === 'navigation_prev'; return { page: targetPage, pageId: targetPage.id, transitionVideoUrl: element.transitionVideoUrl, isBack, }; }; ``` ### Page History Management Page history is managed by the shared `usePageNavigation` hook (used by both RuntimePresentation and constructor): ```typescript import { usePageNavigation } from '../hooks/usePageNavigation'; // Hook provides unified history management with browser-like behavior const { currentPageId: selectedPageId, pageHistory, previousPageId, applyPageSelection, getNavigationContext, } = usePageNavigation({ pages, defaultPageId: initialPageId, trackHistory: true, }); // applyPageSelection handles history automatically: // - Forward (isBack=false): appends to history, trimmed to MAX_HISTORY_LENGTH=50 // - Back (isBack=true): pops from history if target matches previousPageId applyPageSelection(targetPageId, isBack); // getNavigationContext provides context for history-based back navigation const navContext = getNavigationContext(); // Returns: { currentPageSlug, previousPageId } const navTarget = resolveNavigationTarget(element, pages, navContext); ``` **History Behavior:** ``` Forward: A → B → C → history: [A, B, C] Back to B (isBack=true): → history: [A, B] ✓ Pops C Back to A (isBack=true): → history: [A] ✓ Pops B Forward to D: → history: [A, D] ✓ Adds D ``` ## Transition Playback ### With Video Transition ```typescript // useTransitionPlayback handles video playback with preload cache integration const { phase, isBuffering, isReversing, cancel, forceComplete } = useTransitionPlayback({ videoRef, transition: transitionConfig, // { videoUrl, storageKey, reverseMode, reverseVideoUrl, targetPageId, isBack } // onComplete receives isBack flag for proper history management onComplete: (targetPageId, isBack) => { // Transition finished, switch to target page pageSwitch.switchToPage(targetPage, () => { // usePageNavigation hook: pops history on back, appends on forward applyPageSelection(targetPageId, isBack ?? false); }); }, preload: { preloadedUrls: preloadOrchestrator.preloadedUrls, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, }, }); ``` **Storage Key Mapping:** The `storageKey` (raw storage path like `assets/project/transition.mp4`) is preserved for cache lookup because presigned URL signatures change on each resolution. The lookup priority is: 1. `getReadyBlobUrl(storageKey)` → O(1) instant (same session) 2. `getCachedBlobUrl(storageKey)` → Cache API (~5ms, post-refresh) 3. Fallback to resolved URL lookup ### Reverse Playback When navigating back with `transitionReverseMode = 'auto_reverse'`: - Video plays from end to beginning using `useReversePlayback` hook - Supports native `playbackRate = -1` or frame-stepping fallback ```typescript const { startReverse, stopReverse, isReversing, isBuffering, canUseNativeReverse, } = useReversePlayback({ videoRef, onComplete: finishOverlayTransition, preloadedUrls, videoUrl, getCachedBlobUrl, }); // For back navigation with auto_reverse mode if (isBack && transitionReverseMode === 'auto_reverse') { startReverse(); } ``` **Reverse Mode Options:** | Mode | Behavior | |------|----------| | `none` | No transition on back navigation | | `auto_reverse` | Same video plays in reverse | | `separate_video` | Uses `reverseVideoUrl` for back navigation | ## Constructor Configuration ### Setting Up Navigation **File:** `frontend/src/pages/constructor.tsx` When creating/editing navigation elements: 1. Select element type (`navigation_next` or `navigation_prev`) 2. Choose target page from dropdown (shows page names/slugs) 3. Optionally select transition video from assets 4. Configure transition settings (duration, reverse support) ```typescript // Saving navigation element const elementData = { type: selectedElementType, targetPageSlug: targetPage?.slug, // Save slug, not ID transitionVideoUrl: selectedTransitionVideo?.cdn_url, transitionDurationSec: transitionDuration, transitionReverseMode: supportsReverse ? 'auto_reverse' : undefined, reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined, }; ``` ### Extracting Navigation Links for Preloading **File:** `frontend/src/lib/extractPageLinks.ts` The `extractPageLinksAndElements` utility extracts navigation targets and preloadable elements from pages: ```typescript import { extractPageLinksAndElements } from '../lib/extractPageLinks'; // Extract from all pages (filtered by environment) const filteredPages = pages.filter(p => p.environment === environment); const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages); // pageLinks: Array of navigation connections // [{ sourcePageId, targetPageSlug, transitionVideoUrl, supportsReverse }] // preloadElements: Array of elements with preloadable assets // [{ pageId, type, imageUrl, videoUrl, transitionVideoUrl, ... }] ``` ### Building Neighbor Graph **File:** `frontend/src/hooks/useNeighborGraph.ts` The neighbor graph is built from pageLinks for preload prioritization: ```typescript const neighborGraph = useNeighborGraph({ pages: filteredPages, pageLinks, // From extractPageLinksAndElements currentPageId, maxDepth: 1, // Only immediate neighbors (reduced from 2) }); // Returns pages reachable within maxDepth hops const neighborsToPreload = neighborGraph.getNeighbors(currentPageId); ``` **Preload Priority for Navigation Assets:** | Asset Type | Priority | Notes | |------------|----------|-------| | Transition video | +150 | Highest - needed immediately on click | | Background image | +100 | Required for page display | | Audio | +50 | Background audio tracks | | Video | +30 | Can stream, lower priority | ## TypeScript Types **File:** `frontend/src/types/constructor.ts` ```typescript // Navigation-related fields in CanvasElement interface CanvasElement extends BaseCanvasElement { id: string; type: CanvasElementType; // 'navigation_next' | 'navigation_prev' | ... label: string; // Position xPercent: number; yPercent: number; // Navigation (for navigation_next, navigation_prev types) navType?: NavigationButtonKind; // 'forward' | 'back' navDisabled?: boolean; /** @deprecated Use targetPageSlug instead */ targetPageId?: string; targetPageSlug?: string; transitionVideoUrl?: string; transitionReverseMode?: 'auto_reverse' | 'separate_video'; reverseVideoUrl?: string; transitionDurationSec?: number; // Styling iconUrl?: string; // ... extends ElementStyleProperties for CSS styling } // Navigation button direction type type NavigationButtonKind = 'forward' | 'back'; ``` **File:** `frontend/src/types/presentation.ts` ```typescript // Navigation target resolved from element interface NavigationTarget { page: RuntimePage; pageId: string; transitionVideoUrl?: string; isBack: boolean; } // Element with navigation properties (for click handling) interface NavigableElement { id: string; type: string; targetPageSlug?: string; targetPageId?: string; // Legacy transitionVideoUrl?: string; navType?: 'forward' | 'back'; navDisabled?: boolean; } // Transition phase states type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'reversing' | 'completed'; ``` ## File References | File | Purpose | |------|---------| | `frontend/src/pages/constructor.tsx` | Navigation element configuration | | `frontend/src/components/RuntimePresentation.tsx` | Runtime navigation execution | | `frontend/src/lib/extractPageLinks.ts` | Extract navigation links and preload elements from pages | | `frontend/src/lib/navigationHelpers.ts` | Navigation target resolution and direction detection | | `frontend/src/hooks/usePageSwitch.ts` | Page navigation with preloaded blob URLs | | `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with ready blob URL management | | `frontend/src/hooks/usePageNavigation.ts` | History and page state management | | `frontend/src/hooks/useNeighborGraph.ts` | Preload graph from navigation | | `frontend/src/hooks/useReversePlayback.ts` | Reverse video playback (native or frame-stepping) | | `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback with preloaded URLs | | `frontend/src/types/constructor.ts` | Element type definitions | | `frontend/src/types/presentation.ts` | Navigation target and element interfaces | | `frontend/src/config/preload.config.ts` | Preload priority weights and settings | `extractPageLinks.ts` also extracts nested Info Panel `target_page` destinations from `infoPanelSections`: - section-level header/title/text click destinations - span item click destinations - image/card/video/360 item click destinations External URL destinations are not added to the neighbor graph. Info Panel nested media URLs (`imageUrl`, `videoUrl`, `iconUrl`, and header images) are recursively extracted into preload elements. ## Environment Filtering Pages are filtered by environment before navigation resolution: ```typescript // Filter pages by current environment (dev, stage, production) const filteredPages = pages.filter(p => p.environment === environment); // Extract navigation links only from same-environment pages const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages); // Navigation only resolves within the same environment const targetPage = filteredPages.find(p => p.slug === element.targetPageSlug); ``` **Environment Contexts:** | Context | Environment | Notes | |---------|-------------|-------| | Constructor | `dev` | Editing/preview mode | | Stage preview | `stage` | Pre-production review | | Public runtime | `production` | Published tour playback | Navigation links pointing to slugs that don't exist in the current environment will fail silently. ## Known Considerations ### 1. Slug Uniqueness Slugs must be unique within project+environment. The system validates this on page creation/update. ### 2. Missing Target Pages If `targetPageSlug` references a non-existent page in the current environment, navigation silently fails. UI should handle gracefully. ### 3. Transition Reverse Support Not all transitions look good in reverse. Use `transitionReverseMode: 'separate_video'` with a dedicated `reverseVideoUrl` for directional animations, or omit reverse mode entirely. ### 4. Preloading Navigation elements drive preloading. Transition videos have highest priority (+150) and are preloaded first. The neighbor graph uses `maxDepth: 1` (immediate neighbors only). ### 5. Instant Navigation with Preloaded Assets When assets are preloaded, `usePageSwitch` uses `getReadyBlobUrl(storageKey)` for O(1) instant lookup of pre-decoded blob URLs, eliminating any delay or flash during navigation. Lookups prioritize storage keys (e.g., `assets/project/bg.jpg`) over resolved URLs because storage keys are canonical and don't change when presigned URLs are regenerated.