# Runtime Presentation Page - E2E Documentation
## Overview
The Runtime Presentation system is a full-screen, presentation-mode viewer for virtual tours. It enables end users to navigate through tour pages with transitions, rich media content, and offline support. Unlike the Constructor (edit mode), Runtime is optimized for public viewing with intelligent preloading and smooth transitions.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Access Routes │
│ /p/[projectSlug] │ /p/[projectSlug]/stage │ /runtime │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ RuntimePresentation Component │
│ Page Rendering │ Navigation │ Transitions │ Offline Support │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Hooks Layer │
│ usePageDataLoader │ useProjectAssets │ usePreloadOrchestrator │
│ usePageSwitch │ useTransitionPlayback │ useBackgroundTransition│
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Component Layer │
│ RuntimeElement │ UiElementRenderer │ GalleryCarouselOverlay │
│ RuntimeControls (Offline + Fullscreen + Sound) │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Helper Libraries │
│ extractPageLinks │ navigationHelpers │ assetUrl │ logger │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ Projects API │ Tour Pages API │ S3 Direct Download │
└─────────────────────────────────────────────────────────────────┘
```
---
## Access Modes
### Production Mode
**Route:** `/p/[projectSlug]`
- Public access without authentication
- Shows only production environment pages
- Full offline support with OfflineToggle
### Stage Mode
**Route:** `/p/[projectSlug]/stage`
- Testing environment for previewing changes before production
- Shows only `stage` environment pages
- Environment badge displayed
- Content published from Constructor via "Save to Stage" button
### Admin Runtime Mode
**Route:** `/runtime`
- Internal admin testing
- Requires authentication
- Can test any project
### Environment Model
Content flows through three environments:
```
┌─────────────────────────────────────────────────────────────────┐
│ Constructor (Edit) Stage (Preview) Production (Live) │
│ dev ──────► stage ──────► production │
│ "Save to Stage" "Publish" │
└─────────────────────────────────────────────────────────────────┘
```
| Environment | Purpose | Access |
|-------------|---------|--------|
| `dev` | Active editing in Constructor | Constructor only (not Runtime) |
| `stage` | Preview/testing before publish | `/p/[slug]/stage` route |
| `production` | Live public presentation | `/p/[slug]` route |
**Note:** The `dev` environment is only accessible in the Constructor. Runtime always shows either `stage` or `production` content.
---
## Runtime Context Detection
**Primary Method: Route-Based Environment**
The platform uses **route-based environment access**, not subdomains. The environment is determined by the frontend route and passed to the backend via headers.
| Route | Environment | Component |
|-------|-------------|-----------|
| `/p/[projectSlug]` | production | `pages/p/[projectSlug]/index.tsx` |
| `/p/[projectSlug]/stage` | stage | `pages/p/[projectSlug]/stage.tsx` |
**Frontend sends environment via headers:**
```typescript
// RuntimePresentation.tsx
const apiConfig = {
headers: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment, // 'production' | 'stage'
},
};
```
**Backend middleware** (`middleware/runtime-context.ts`):
```javascript
// Reads both hostname AND headers for flexibility
req.runtimeContext = {
mode: detectFromHostname(hostname), // Fallback for subdomain access
headerEnvironment: req.headers['x-runtime-environment'],
headerProjectSlug: req.headers['x-runtime-project-slug'],
};
```
**Backend DB filtering** (`db/api/runtime-context.ts`):
```javascript
// Uses header-based environment when hostname detection returns 'admin'
// SECURITY: Only 'production' and 'stage' allowed from headers
// 'dev' is blocked to prevent unauthorized access to dev data
```
---
## Project Loading
### useProjectAssets Hook
The component uses the `useProjectAssets` hook to resolve project assets (favicon, og_image) to presigned URLs for meta tags:
```typescript
// Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
```
This hook:
- Extracts storage paths from URLs (handles both `storage_key` and legacy `cdn_url` formats)
- Queues presigned URL requests for relative paths
- Resolves to playback URLs (presigned if available, otherwise proxy)
### Global UI Controls
Runtime controls for offline mode, fullscreen, and global sound mute resolve
through `global_ui_control_defaults`, `project_ui_control_settings`, and the
current page's `global_ui_controls_settings_json`. Dimensions and positions use
canvas-relative percentages, so controls keep consistent proportions and spacing
across screens for projects with the same canvas ratio.
Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty
values fall back to the built-in MDI icons.
Embedded runtime fullscreen uses the same wrapper fallback as Info Panel image
detail fullscreen: native document fullscreen first, same-origin iframe
fullscreen second, then parent `tour-builder:request-fullscreen` postMessage.
Exit from cross-origin wrapper fullscreen posts `tour-builder:exit-fullscreen`.
### usePageDataLoader Hook
The component uses the shared `usePageDataLoader` hook for data loading:
```typescript
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader({
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
});
```
### Data Fetching Flow
```
1. usePageDataLoader fetches project
└── GET /projects?slug={projectSlug}
└── Headers: X-Runtime-Project-Slug, X-Runtime-Environment
2. usePageDataLoader fetches tour pages
└── GET /tour_pages?project={projectId}
└── Pages include ui_schema_json with navigation elements
3. STRICT environment filtering (both layers)
└── Backend: Filters by X-Runtime-Environment header
└── Frontend: .filter((p) => p.environment === environment)
4. Pages sorted by sort_order
5. initialPageId returned (first page by sort_order)
```
**Note:** Navigation targets, transitions, and page elements are all stored in `tour_pages.ui_schema_json`. No separate API calls needed.
### Environment Isolation (Critical)
**IMPORTANT:** Strict environment filtering prevents data leaks.
**Frontend Filter** (`RuntimePresentation.tsx`):
```typescript
// STRICT: Only exact environment match - no fallbacks!
const envFilteredPages = pageRows
.filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
```
**Backend Filter** (`db/api/runtime-context.ts`):
```javascript
// Header-based filtering for route-based access
// Only 'production' and 'stage' allowed - 'dev' is BLOCKED
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
```
| Route | Shows | Never Shows |
|-------|-------|-------------|
| `/p/cardiff` | `environment='production'` only | dev, stage |
| `/p/cardiff/stage` | `environment='stage'` only | dev, production |
### Public Runtime Headers
```typescript
const headers = {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment, // 'production' | 'stage'
};
```
### Public Field Filtering
The `runtime-public.ts` middleware sanitizes responses for unauthenticated requests:
**Projects:** id, name, slug, description, logo_url, favicon_url, og_image_url
**Tour Pages:** id, projectId, environment, source_key, name, slug, sort_order, background_image_url, background_video_url, background_embed_url, background_audio_url, background_loop, requires_auth, ui_schema_json
**Project Audio Tracks:** id, projectId, environment, source_key, name, slug, url, loop, volume, sort_order, is_enabled
**Note:** Navigation links and transitions are stored in `tour_pages.ui_schema_json`, not as separate entities.
### Transition Settings (Public Access)
The RuntimePresentation fetches transition settings from two endpoints:
```typescript
// On mount - fetch global defaults (always public)
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
// When project loads - fetch project-specific settings
useEffect(() => {
if (project?.id) {
dispatch(fetchProjectTransitionSettings({
projectId: project.id,
environment,
apiHeaders: runtimeApiHeaders,
}));
}
}, [dispatch, project?.id, environment, runtimeApiHeaders]);
```
**Authentication Model (URL-path based):**
| Endpoint | Environment | Auth Required |
|----------|-------------|---------------|
| `GET /global-transition-defaults` | n/a | **No** (always public) |
| `GET /project-transition-settings/project/:id/env/production` | production | No for public projects; JWT + staff permission or DB access grant for private projects |
| `GET /project-transition-settings/project/:id/env/stage` | stage | Yes |
Public presentations (`/p/[slug]`) work in incognito mode without authentication.
Private production presentations use the same runtime route, but require JWT and
the project visibility/access grant flow described in
[private-production-presentations.md](../../documentation/private-production-presentations.md).
---
## Page Rendering
### Z-Index Layering Structure
The RuntimePresentation uses a specific z-index hierarchy to ensure proper layering of components:
| Layer | Z-Index | Component | Purpose |
|-------|---------|-----------|---------|
| Background | `z-1` | Background image/video | Page background content |
| Backdrop blur | `z-5` | BackdropPortalProvider | Blur effects for elements |
| Carousel background | `z-10` | CarouselElement (portal) | Full-width carousel background |
| Previous background | `z-10` | Previous bg overlay | Shows during page transitions |
| Carousel controls | `z-30` | CarouselElement (portal) | Carousel prev/next buttons |
| **Page elements** | **`z-40`** | Elements container | Navigation buttons, UI elements |
| UI controls | settings-driven | RuntimeControls | Canvas-relative global controls |
| Transition overlay | `z-50` | Transition video | Page transition videos |
**Key Design Decisions:**
- Page elements at `z-40` ensures they appear above carousel controls (`z-30`) for proper click handling
- Carousel uses portals to `document.body` with `position: fixed` for full-screen display
- Carousel wrappers have `pointer-events-none` with buttons having `pointer-events-auto`
- Background video at `z-1` keeps it below backdrop blur effects
### RuntimeControls Component
**Location:** `src/components/Runtime/RuntimeControls.tsx`
The RuntimeControls component renders the offline toggle, fullscreen button,
and optional global sound button. Each button has independent canvas-relative
positioning, dimensions, styling, order, visibility, disabled state, and
state-specific icon/color settings. The sound button is presentation-level: if
any page in the loaded runtime environment has background audio, unmuted
background video, hover/click effects, media player sound, or
gallery/info-panel video, the button is visible on every page in that
presentation.
**Key Features:**
| Feature | Implementation |
|---------|----------------|
| Canvas-relative positioning | `xPercent`/`yPercent` are resolved inside the visible canvas bounds |
| Canvas-relative sizing | `buttonSizePercent`, `iconSizePercent`, and `borderRadiusPercent` resolve from canvas width |
| Pinch-zoom resistance | Uses `visualViewport` API to counter-scale during pinch-zoom |
| Configurable z-index | Runtime uses control settings; constructor clamps controls below editor chrome |
| Global sound toggle | Uses `presentationHasAudio`, `useVideoSoundControl`, and `backgroundAudioController` to mute/unmute background audio, hover/click effects, audio/video player elements, and gallery/info-panel videos across the whole presentation |
| Optional controls | `showOfflineButton`, `showFullscreenButton`, and `showSoundButton` gate rendering without deleting settings |
**iOS Pinch-Zoom Fix:**
iOS browsers (all use WebKit engine) have inconsistent scaling behavior between `rem` units and other CSS values during pinch-zoom gestures. The RuntimeControls uses a `useCounterZoom` hook that:
1. Listens to `visualViewport.resize` and `visualViewport.scroll` events
2. Reads `visualViewport.scale` to detect current pinch-zoom level
3. Applies an inverse `transform: scale(1/zoomLevel)` to maintain constant visual size
```typescript
// Counter-scale formula
const counterScale = 1 / visualViewport.scale;
// Applied as: transform: scale(counterScale), transformOrigin: 'top right'
```
**Sub-components:**
- `ControlButton` - Styled button with hover states matching BaseButton colors
- `ControlIcon` - built-in SVG or custom asset icon renderer
- `OfflineControl` - Offline download toggle with status indicators
### UI Schema Structure
Pages store elements in `ui_schema_json`:
```typescript
interface UISchema {
elements: UISchemaElement[];
}
interface UISchemaElement {
id: string;
type: ElementType;
// Position (percentage-based)
xPercent: number;
yPercent: number;
rotation?: number;
// Dimensions
width?: string;
height?: string;
// Styling
opacity?: number;
padding?: string;
margin?: string;
fontSize?: string;
fontFamily?: string;
color?: string;
backgroundColor?: string;
// Content
iconUrl?: string;
imageUrl?: string;
mediaUrl?: string;
// Navigation (stored in ui_schema_json)
targetPageSlug?: string; // Slug-based navigation (consistent across environments)
navType?: 'forward' | 'back';
navLabel?: string;
navDisabled?: boolean; // Runtime ignores activation while preserving visual effects
transitionVideoUrl?: string;
// Type-specific
descriptionTitle?: string;
descriptionText?: string;
tooltipTitle?: string;
tooltipText?: string;
galleryCards?: GalleryCard[];
carouselSlides?: CarouselSlide[];
}
```
### Element Rendering
Elements are rendered using shared components for WYSIWYG consistency with the constructor:
```tsx
{/* Page elements - z-40 ensures they appear above carousel controls (z-30) */}
{pageElements.map((element: CanvasElement) => (
handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex)}
/>
))}
// URL resolver uses preloaded blob URLs when available (instant display)
const resolveUrlWithBlob = useCallback(
(url: string | undefined): string => {
if (!url) return '';
// Try storage key first, then resolved URL
const blobUrl =
preloadOrchestrator?.getReadyBlobUrl(url) ||
preloadOrchestrator?.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
if (blobUrl) return blobUrl;
return resolveAssetPlaybackUrl(url);
},
[preloadOrchestrator],
);
```
**Component Architecture:**
- `RuntimeElement` handles positioning, rotation, and interactive effects (hover/focus/active)
- `RuntimeElement` delegates content rendering to `UiElementRenderer`
- `UiElementRenderer` delegates to per-type components (NavigationElement, GalleryElement, etc.)
- Both Constructor (`CanvasElement`) and Runtime (`RuntimeElement`) use `UiElementRenderer` for WYSIWYG consistency
### RuntimeElement Component
Wraps each UI element with positioning, interactive effects, and delegates content rendering to `UiElementRenderer`:
| Feature | Description |
|---------|-------------|
| **Percentage positioning** | Uses `xPercent`, `yPercent` for responsive placement |
| **Transform** | Centers element and applies rotation |
| **Interactive effects** | Hover, focus, and active state styling via `useElementEffects` hook |
| **Click handling** | Propagates clicks to parent handler; disabled navigation/info panel elements keep visual hover/focus/active effects but ignore click and keyboard activation |
| **Gallery card clicks** | `onGalleryCardClick` prop opens GalleryCarouselOverlay |
| **Content delegation** | Renders content via `UiElementRenderer` for WYSIWYG consistency |
| **URL resolution** | Passes `resolveUrl` prop for blob URL support |
### UiElementRenderer Component
Unified element rendering component - single source of truth used by both `RuntimeElement` (presentation) and `CanvasElement` (constructor) for WYSIWYG consistency.
**Architecture:**
```
UiElementRenderer (main entry point)
├── useElementWrapperStyle (shared styling hook)
└── Per-type components:
├── NavigationElement (navigation_next, navigation_prev)
├── GalleryElement (gallery) → can trigger GalleryCarouselOverlay
├── TooltipElement (tooltip)
├── DescriptionElement (description)
├── CarouselElement (carousel)
├── LogoElement (logo)
├── SpotElement (spot/hotspot)
├── VideoPlayerElement (video_player)
├── AudioPlayerElement (audio_player)
└── PopupElement (popup)
GalleryCarouselOverlay (fullscreen overlay)
├── Swipe navigation between images
├── Customizable prev/next/back buttons
└── Percentage-based button positioning
```
| Element Type | Component | Rendered Content |
|--------------|-----------|------------------|
| `navigation_*` | NavigationElement | Icon + label with nav styling |
| `spot` | SpotElement | Hotspot/clickable area with icon |
| `description` | DescriptionElement | Title + text block |
| `tooltip` | TooltipElement | Icon with popover |
| `video_player` | VideoPlayerElement | HTML5 video |
| `audio_player` | AudioPlayerElement | HTML5 audio |
| `gallery` | GalleryElement | Image grid from galleryCards |
| `carousel` | CarouselElement | Slideshow from carouselSlides |
| `logo` | LogoElement | Logo image element |
| `popup` | PopupElement | Modal/popup overlay |
### Element Types
| Type | Rendering |
|------|-----------|
| `navigation_next` | Button with icon, navigates forward |
| `navigation_prev` | Button with icon, navigates backward |
| `spot` | Hotspot/clickable area with icon |
| `description` | Title + text block with styling |
| `tooltip` | Icon with hover popover |
| `video_player` | HTML5 video with controls |
| `audio_player` | HTML5 audio with controls |
| `gallery` | Grid of images from galleryCards |
| `carousel` | Slideshow from carouselSlides |
| `logo` | Logo image element |
| `popup` | Modal/popup overlay |
---
## Page Navigation
### Navigation Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. User clicks navigation element │
│ └── handleElementClick(element) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Extract navigation data & resolve slug │
│ └── targetPageSlug → resolve to targetPageId │
│ └── navType, transitionVideoUrl │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Determine if back navigation │
│ └── navType === 'back' OR type === 'navigation_prev' │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Wait for target page images │
│ └── Decode images to prevent white flash │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Has Transition? │ │ No Transition │
│ YES │ │ Direct page switch │
└──────────┬───────────┘ └──────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Show transition overlay via useTransitionPlayback │
│ └── Full-screen video playback (forward or reverse) │
│ └── Images pre-decode DURING video playback │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 6. Video ends → onComplete callback fires │
│ └── waitForPageImages() completes instantly (pre-decoded) │
│ └── Switch to target page │
│ └── Update page history │
│ └── Double requestAnimationFrame ensures page painted │
│ └── THEN remove overlay (setTransitionPreview(null)) │
└─────────────────────────────────────────────────────────────────┘
```
### Element Click Handler
The click handler uses shared navigation helpers from `lib/navigationHelpers.ts`:
```typescript
const handleElementClick = useCallback(
(element: any) => {
if (isNavigationType(element.type) && element.navDisabled) {
return;
}
// Block navigation while transition is actively playing or buffering
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
return;
}
// Use shared helper to resolve navigation target
const navTarget = resolveNavigationTarget(element, pages);
if (navTarget) {
navigateToPage(
navTarget.pageId,
navTarget.transitionVideoUrl,
navTarget.isBack,
);
}
},
[navigateToPage, pages, transitionPhase, isBuffering],
);
```
### Navigation Helpers (`lib/navigationHelpers.ts`)
| Helper | Purpose |
|--------|---------|
| `resolveNavigationTarget(element, pages)` | Resolves element's `targetPageSlug` to page ID, returns `{pageId, transitionVideoUrl, isBack}` or `null` |
| `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) |
| `getNavigationDirection(element)` | Returns `'forward'` or `'back'` based on `navType` or element type |
**Note:** Navigation elements store `targetPageSlug` (not UUID) because slugs are consistent across environments (dev/stage/production). The slug is resolved to a page ID at navigation time.
### Page History Management
Page history is now managed by the shared `usePageNavigation` hook (replacing manual `useState`):
```typescript
// usePageNavigation hook provides unified history management
const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId,
trackHistory: true,
});
// applyPageSelection handles history with browser-like behavior:
// - Forward: appends to history (trimmed to MAX_HISTORY_LENGTH=50)
// - Back (isBack=true): pops from history if target matches previous page
applyPageSelection(targetPageId, isBack);
// getNavigationContext provides context for history-based navigation
const navContext = getNavigationContext();
// Returns: { currentPageSlug, previousPageId }
const navTarget = resolveNavigationTarget(element, pages, navContext);
```
---
## Transition Execution
### Transition State
```typescript
// Transition state for useTransitionPlayback hook
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string; // Resolved URL for playback
storageKey: string; // Raw storage path for cache lookup (enables presigned URL independence)
isReverse: boolean;
} | null>(null);
```
### useTransitionPlayback Hook Integration
The `useTransitionPlayback` hook handles both forward and reverse playback with smooth transitions:
```typescript
// State for coordinating transition completion with background readiness
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false);
// Hook returns both isBuffering and phase for granular opacity control
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey, // Raw path for instant cache lookup
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
targetPageId: transitionPreview.targetPageId,
displayName: 'Transition',
isBack: transitionPreview.isReverse, // Pass through for history management
}
: null,
// onComplete receives isBack flag for proper history management
onComplete: async (targetPageId, isBack) => {
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
// Use shared hook to resolve blob URLs and switch page
await pageSwitch.switchToPage(targetPage, () => {
// usePageNavigation hook: pops history on back, appends on forward
applyPageSelection(targetPageId, isBack ?? false);
});
setIsBackgroundReady(false);
// Signal transition complete, wait for background
setPendingTransitionComplete(true);
} else {
// Cleanup when no target page
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
},
features: {
useBlobUrl: true, // Enables seeking for reverse playback
preDecodeImages: false, // Overlay shows last frame while new bg loads behind
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl, // Instant O(1) lookup by storage key
},
});
```
### useBackgroundTransition Hook Integration
The `useBackgroundTransition` hook handles crossfade effects for **non-video navigation only**:
```typescript
// Use shared background transition hook for crossfade effects
// NOTE: fadeOut config is NOT used for video transitions.
// Video transitions end instantly (last frame = new page, then overlay removed).
// fadeIn is used for non-video navigation (crossfade 700ms).
const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } =
useBackgroundTransition({
pageSwitch,
// No fadeOut - video transitions don't use fade
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
```
**Video Overlay Removal (separate effect, no hook involvement):**
```typescript
// Video transition overlay removal - instant (no fade) when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
}, [pendingTransitionComplete, isBackgroundReady]);
```
**How it works (instant removal, no fade):**
1. When `pendingTransitionComplete && isBackgroundReady`, effect triggers cleanup
2. **Instant** overlay removal (no fade animation):
- `video.removeAttribute('src'); video.load()` - cleanup video
- `setTransitionPreview(null)` - removes overlay immediately
- `setPendingTransitionComplete(false)` - reset state
**Why no fade for video transitions:**
- Video itself IS the transition effect
- First frame of video = old page background
- Last frame of video = new page background
- Fading would create visual discontinuity
### Transition Overlay Rendering
```tsx
{/* Container hidden while buffering to prevent black flash */}
{/* Video first frame should match old page background */}
{/* Overlay removed instantly when new background ready */}
{transitionPreview && (
)}
```
**TransitionPreviewOverlay behavior:**
- `containerOpacity = isBuffering ? 0 : 1` - hides entire container while loading
- Old page visible through transparent overlay until video ready
- Video appears instantly when ready (first frame = old page)
- Overlay removed instantly when new background ready
### Key Features
| Feature | Description |
|---------|-------------|
| **Container hidden while buffering** | Prevents black flash, old page visible |
| **Instant overlay appearance** | Video first frame matches old page |
| **Instant overlay removal** | No fade-out, removed when bg ready |
| **useBackgroundTransition** | Shared hook handles crossfade (non-video nav) |
| **isBackgroundReady state** | Tracks when new page background is fully rendered |
| **pendingTransitionComplete state** | Signals video ended, waiting for background |
| **Blob URL support** | Enables seeking for reverse playback |
| **Video cleanup** | `removeAttribute('src')` + `load()` prevents memory leaks |
| **Navigation blocking** | `isTransitionBlocking()` prevents navigation during playback |
### Reverse Playback Modes
The hook internally uses `useReversePlayback` with strategies:
1. **Native Reverse** - `video.playbackRate = -1` (Chrome, Edge)
2. **Frame-Stepping** - Manual frame-by-frame reverse (Safari, Firefox)
---
## Background Media
### Background Image
The background uses both CSS `background-image` (for immediate display) and an image element for proper loading detection. **Blob URLs use native `
` to prevent re-fetch issues, while regular URLs use Next.js Image:**
```tsx
{/* Image element ensures proper loading for waitForPageImages() */}
{backgroundImageUrl && !backgroundVideoUrl && (
{backgroundImageUrl.startsWith('blob:') ? (
// Native img for blob URLs - no re-fetch on re-render

setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
/>
) : (
// Next.js Image for regular URLs - optimization benefits
setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
/>
)}
)}
```
**Why conditional rendering?**
- Next.js `` re-fetches `src` on every re-render, even with `unoptimized`
- Blob URLs are already in memory - no need for Next.js optimization
- Native `
` is cached by browser and doesn't re-fetch
- This prevents thousands of unnecessary blob URL requests during animations
**Why both CSS and Image element?**
- CSS `background-image` provides immediate visual (from browser cache)
- Image element triggers `onLoad` callback for proper state tracking
- `waitForPageImages()` uses `img.decode()` which works with both image types
- This prevents black flash when transitioning between pages
### Background Video
Background videos support configurable playback settings including custom start/end times.
**Video Playback Settings (from `tour_pages`):**
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `background_video_autoplay` | BOOLEAN | true | Autoplay on load |
| `background_video_loop` | BOOLEAN | true | Loop continuously |
| `background_video_muted` | BOOLEAN | true | Mute audio (required for autoplay) |
| `background_video_start_time` | DECIMAL(10,1) | null | Start time in seconds |
| `background_video_end_time` | DECIMAL(10,1) | null | End/loop time in seconds |
**DECIMAL Parsing (Critical):**
Sequelize DECIMAL fields return strings from the database (e.g., `"2.5"` not `2.5`). These must be parsed before use:
```typescript
// Parse DECIMAL strings for video time settings
const videoStartTime =
selectedPage?.background_video_start_time != null
? parseFloat(String(selectedPage.background_video_start_time))
: null;
const videoEndTime =
selectedPage?.background_video_end_time != null
? parseFloat(String(selectedPage.background_video_end_time))
: null;
```
**useBackgroundVideoPlayback Hook:**
The `useBackgroundVideoPlayback` hook handles start/end time control via video events:
```typescript
const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({
videoUrl: backgroundVideoUrl,
autoplay: videoAutoplay,
loop: videoLoop,
muted: videoMuted,
startTime: videoStartTime, // Must be parsed from DECIMAL string
endTime: videoEndTime, // Must be parsed from DECIMAL string
});
```
**Note:** When `endTime` is set, native HTML5 `loop` is disabled. The hook handles looping via `timeupdate` event, seeking back to `startTime`.
**Rendering:**
```tsx
{backgroundVideoUrl && (
)}
```
### Background Ready State
For pages without images or with videos, background is immediately ready:
```tsx
useEffect(() => {
if (!selectedPage?.background_image_url || selectedPage?.background_video_url) {
setIsBackgroundReady(true);
}
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
```
---
## Preloading Integration
### Extracting Page Links and Elements
Navigation and preload data is extracted from `ui_schema_json` using the shared utility:
```typescript
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// Extract page links and preload elements from ui_schema_json
const { pageLinks, preloadElements } = useMemo(() => {
return extractPageLinksAndElements(pages);
}, [pages]);
```
This enables:
1. **pageLinks** - Navigation connections for the neighbor graph
2. **preloadElements** - Asset URLs (icons, media, transitions) for preloading
Info Panel `target_page` click destinations are extracted from nested `infoPanelSections` as page links. Runtime trigger clicks are ignored when `infoPanelDisabled: true`. When a page renders, every enabled Info Panel element with `infoPanelOpenByDefault: true` opens automatically; missing or false values preserve the legacy closed-by-default behavior. Runtime tracks open Info Panels by element ID, and binds image detail state, fullscreen gallery state, and media-section selected image state to the originating panel ID. Multiple open Info Panels share a single fullscreen backdrop: backdrop clicks close the whole open group, while each panel close button closes only that panel. Runtime clicks from header/title/text sections, span items, and image/card/video/360 items resolve `targetPageSlug` against the current environment's pages; external URL actions open a new browser tab. Cards/media sections support two rendering modes: `mediaOpenMode: 'panel'` opens the item in the side preview panel, and `mediaOpenMode: 'fullscreen'` opens the section's media items in the shared fullscreen `GalleryCarouselOverlay`; video items use `videoUrl` and render with native video controls in both modes. Image detail panels render through a body-level portal so their fullscreen state is not trapped below the canvas stacking context or runtime global controls. When a presentation is already in browser fullscreen, image detail fullscreen uses local expansion and can return to panel view without exiting presentation fullscreen. Embedded presentations first request native panel fullscreen, then same-origin iframe fullscreen, then post `tour-builder:request-fullscreen` to the parent page before falling back to local iframe-viewport fullscreen. Media items with `useAsBackground` replace the current screen background through the same image/video/360 background renderer used by page backgrounds. While the fullscreen gallery overlay is open, runtime top-right controls are not rendered, and 360 iframes in the overlay are not granted iframe fullscreen permission. 360 iframe URLs are normalized through `buildChromeFreeEmbedUrl`; Kuula embeds are rendered with `fs=0` so provider fullscreen controls do not duplicate platform controls while source playback/autorotate parameters are preserved.
### usePreloadOrchestrator
```typescript
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks, // From extractPageLinksAndElements
elements: preloadElements, // From extractPageLinksAndElements
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
});
// Available methods
const {
isPreloading,
preloadedUrls,
queueLength,
getCachedBlobUrl, // Async: creates blob URL from cache (by storage key or resolved URL)
isUrlPreloaded,
getReadyBlobUrl, // Instant O(1) lookup by storage key: decoded blob URL ready to display
} = preloadOrchestrator;
// Storage key mapping enables reliable cache lookups
// regardless of which presigned URL was used for download
```
### Storage Key Mapping (Key Feature)
The preload system maps assets by **storage key** (canonical path like `assets/project-123/video.mp4`) in addition to download URLs. This enables cache hits even when presigned URLs change.
```typescript
// Setting transition preview with storage key
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), // Resolved for playback
storageKey: transitionVideoUrl, // Raw path for cache lookup
isReverse: isBack,
});
// Lookup priority in useTransitionPlayback:
// 1. getReadyBlobUrl(storageKey) → instant O(1) Map lookup (same session)
// 2. getCachedBlobUrl(storageKey) → Cache API lookup (~5ms, post-refresh)
// 3. getReadyBlobUrl(resolvedUrl) → fallback
// 4. getCachedBlobUrl(resolvedUrl) → fallback
// 5. Network fetch → last resort
```
**Why storage key mapping matters:**
| Scenario | Download URL | Storage Key | Lookup Result |
|----------|-------------|-------------|---------------|
| Same session | `https://s3...?Sig=ABC` | `assets/vid.mp4` | ✅ Instant via storage key |
| New presigned URL | `https://s3...?Sig=XYZ` | `assets/vid.mp4` | ✅ Instant via storage key |
| Page refresh | N/A (in-memory cleared) | `assets/vid.mp4` | ✅ From Cache API by storage key |
```
### usePageSwitch Integration
The `usePageSwitch` hook provides smooth page transitions using blob URLs from the preload cache:
```typescript
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // Instant O(1)
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, // Fallback
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Switch to a page with smooth background transition
await pageSwitch.switchToPage(targetPage);
// Canvas background uses resolved blob URLs
const backgroundImageSrc =
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
```
**White Flash Prevention:**
1. `getReadyBlobUrl()` - O(1) instant lookup for pre-decoded blob URLs
2. `previousBgImageUrl` - Keeps old background visible during switch
3. `markBackgroundReady()` - Called when new background loads
4. `clearPreviousBackground()` - Fades out overlay after paint confirmed
### Preload Priority
```
Current page assets: priority = 1000 + assetType weight
Neighbor (distance 1): priority = 500 + assetType weight
Asset weights:
- Transition: +150 (highest - needed immediately on navigation)
- Image: +100 (backgrounds load during transition playback)
- Audio: +50
- Video: +30
```
**Note:** `maxNeighborDepth` defaults to 1 (immediate neighbors only). Depth 2 was causing too many requests.
### Image Pre-Decode
```typescript
const waitForPageImages = async (pageId: string) => {
const page = pages.find((p) => p.id === pageId);
const imageUrls: string[] = [];
// Collect background image
if (page.background_image_url) {
imageUrls.push(page.background_image_url);
}
// Collect element images
const schema = JSON.parse(page.ui_schema_json || '{}');
(schema.elements || []).forEach((el) => {
if (el.iconUrl) imageUrls.push(el.iconUrl);
if (el.imageUrl) imageUrls.push(el.imageUrl);
});
// Decode all images
await Promise.all(
imageUrls.map(async (url) => {
const img = new Image();
img.src = url;
try {
await Promise.race([
img.decode(),
new Promise((_, reject) => setTimeout(reject, 2000)),
]);
} catch {
// Ignore decode failures
}
})
);
};
```
---
## Offline Support
### OfflineToggle Component
```tsx
```
### Offline States
| State | UI | Action |
|-------|-----|--------|
| `not_downloaded` | "Download for offline" | Click to start |
| `downloading` | "Downloading 45%" | Progress indicator |
| `downloaded` | "Available offline" | Checkmark icon |
| `error` | "Retry download" | Click to retry |
| `outdated` | "Update available" | Click to update |
### useOfflineMode Hook
```typescript
const {
isOfflineCapable,
isDownloaded,
isDownloading,
status,
progress,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
deleteOfflineData,
estimatedSize,
} = useOfflineMode({
projectId,
projectSlug,
projectName,
});
```
---
## Fullscreen Mode
### Toggle Fullscreen
```typescript
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(async () => {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
}, []);
// Listen for ESC key exit
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement));
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
```
### Fullscreen Button
```tsx
```
---
## Head Meta Tags
The component renders SEO and social sharing meta tags in the `` component:
```tsx
{project?.name || 'Presentation'}
{faviconUrl && }
{ogImageUrl && (
<>
>
)}
{project?.name && (
<>
>
)}
{project?.description && (
<>
>
)}
```
**Meta tags rendered:**
- **favicon** - Project favicon via presigned URL from `useProjectAssets`
- **og:image / twitter:image:src** - Open Graph image for social sharing
- **og:title / twitter:title** - Project name for social sharing
- **og:description / twitter:description** - Project description for social sharing
---
## Gallery Carousel Overlay
When users click on gallery cards, a fullscreen carousel overlay opens for navigating through images.
### State Management
```typescript
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: any;
initialIndex: number;
} | null>(null);
```
### Gallery Card Click Handler
```typescript
const handleGalleryCardClick = useCallback(
(element: any, cardIndex: number) => {
if (element.galleryCards?.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
},
[],
);
```
### GalleryCarouselOverlay Component
The overlay provides:
- Fullscreen image carousel with swipe navigation
- Customizable prev/next/back button icons and positions
- Percentage-based button positioning (like canvas elements)
- Draggable buttons in constructor edit mode
- No runtime chrome controls while open: download, fullscreen, and global mute
buttons are hidden. Gallery videos still read the global muted state.
Info Panel image detail fullscreen is separate from `GalleryCarouselOverlay`.
It is portaled to `document.body` and uses z-index values above
`RuntimeControls` so detail fullscreen controls remain clickable while global
presentation controls stay behind the image view.
```tsx
{activeGalleryCarousel && (
setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
backLabel={activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
isEditMode={false}
/>
)}
```
---
## Mobile/Touch Support
### Touch-Friendly Elements
- Large touch targets for navigation buttons
- `playsInline` attribute on videos for iOS
- Responsive sizing with `w-full h-full object-cover`
- Standard click handlers work for touch events
### Video Autoplay Policies
```tsx
```
---
## Comparison: Runtime vs Constructor
| Aspect | Runtime | Constructor |
|--------|---------|-------------|
| **Purpose** | View presentations | Edit content |
| **Access** | Public or authenticated | Authenticated only |
| **Environment** | `stage` or `production` | `dev` only |
| **Full-screen** | Yes | No |
| **Transitions** | Play forward/reverse | Preview only |
| **Offline Support** | Full caching | Not supported |
| **Preloading** | Neighbor graph (depth 1) | Same (depth 1) |
| **Element Interaction** | Click-to-navigate | Drag-to-edit |
| **Background Video** | Looping auto-play | Static display |
| **UI Schema Source** | `ui_schema_json` | `ui_schema_json` + live edits |
---
## Key Files Reference
### Hooks
| File | Purpose |
|------|---------|
| `hooks/usePageDataLoader.ts` | Project and page data loading (shared with constructor) |
| `hooks/useProjectAssets.ts` | Resolves project assets (favicon, og_image, logo) to presigned URLs |
| `hooks/usePreloadOrchestrator.ts` | Asset preload with blob URL caching (S3 direct → Cache API → blob URLs) |
| `hooks/usePageSwitch.ts` | Smooth page transitions with preload integration |
| `hooks/useTransitionPlayback.ts` | Transition video playback (forward + reverse) |
| `hooks/useBackgroundTransition.ts` | Background fade-out animation coordination |
| `hooks/useBackgroundVideoPlayback.ts` | Background video time control (start/end time) |
| `hooks/useReversePlayback.ts` | Low-level reverse video playback |
| `hooks/useNeighborGraph.ts` | Navigation graph building from pageLinks |
| `hooks/useOfflineMode.ts` | Offline download management |
### Components
| File | Purpose |
|------|---------|
| `components/RuntimePresentation.tsx` | Main presentation component |
| `components/RuntimeElement.tsx` | Element wrapper with positioning and effects |
| `components/UiElements/UiElementRenderer.tsx` | Unified element rendering (WYSIWYG consistency) |
| `components/UiElements/GalleryCarouselOverlay.tsx` | Fullscreen gallery carousel overlay |
| `components/UiElements/shared/useElementWrapperStyle.ts` | Shared styling hook for consistent wrapper styling |
| `components/UiElements/elements/*.tsx` | Per-type element components (NavigationElement, GalleryElement, etc.) |
| `components/Offline/OfflineToggle.tsx` | Offline toggle button |
### Pages
| File | Purpose |
|------|---------|
| `pages/runtime.tsx` | Admin runtime viewer |
| `pages/p/[projectSlug]/index.tsx` | Production presentation |
| `pages/p/[projectSlug]/stage.tsx` | Stage presentation |
### Libraries & Helpers
| File | Purpose |
|------|---------|
| `lib/extractPageLinks.ts` | Extract pageLinks and preloadElements from ui_schema_json |
| `lib/navigationHelpers.ts` | Navigation utilities: resolveNavigationTarget, isTransitionBlocking |
| `lib/assetUrl.ts` | Asset URL resolution (resolveAssetPlaybackUrl) |
| `lib/logger.ts` | Structured logging |
### Configuration & Types
| File | Purpose |
|------|---------|
| `config/preload.config.ts` | Preload settings: priority weights, maxDepth, asset field names |
| `types/runtime.ts` | Runtime type definitions |
| `types/preload.ts` | Preload type definitions (PreloadPageLink, PreloadElement, etc.) |
| `types/presentation.ts` | TransitionPhase type |
### Backend Files
| File | Purpose |
|------|---------|
| `routes/runtime-context.ts` | Context detection endpoint |
| `middlewares/runtime-context.ts` | Context detection middleware |
| `middlewares/runtime-public.ts` | Public field filtering |
---
## Configuration
### Preload Configuration
```typescript
// config/preload.config.ts
{
maxConcurrentDownloads: 3,
neighborGraph: {
maxDepth: 1, // Only preload immediate neighbors
},
priority: {
currentPage: 1000,
neighborBase: 500,
assetType: {
transition: 150, // Highest - needed immediately on navigation
image: 100, // Backgrounds load during transition playback
audio: 50,
video: 30,
},
},
assetFields: {
// Asset URL fields extracted from ui_schema_json
all: ['iconUrl', 'imageUrl', 'mediaUrl', 'transitionVideoUrl', ...],
nested: ['galleryCards', 'carouselSlides'],
},
}
```
### Offline Configuration
```typescript
// config/offline.config.ts
{
cacheNames: {
assets: 'tour-builder-assets-v1',
},
storage: {
indexedDbMinSize: 5 * 1024 * 1024, // 5MB
warningPercent: 80,
criticalPercent: 95,
},
}
```
---
## Performance Optimizations
1. **Storage Key Mapping** - Assets cached by canonical storage path (e.g., `assets/vid.mp4`), enabling cache hits regardless of presigned URL signature changes
2. **Instant Blob URL Lookup** - `getReadyBlobUrl(storageKey)` provides O(1) lookup for pre-decoded blob URLs ready to display
3. **Image Pre-Decode During Playback** - Decode images DURING transition video playback (not after) to eliminate black flash
4. **Double RAF Paint Sync** - Two `requestAnimationFrame` calls ensure new page is painted before overlay removal
5. **Priority Preloading** - Current page assets load first, then immediate neighbors (depth 1)
6. **S3 Direct Download** - Assets downloaded directly from S3 presigned URLs, cached in browser Cache API
7. **Concurrent Downloads** - 3 parallel downloads maximize throughput
8. **Network-Aware** - Adaptive concurrency based on connection speed
9. **Hybrid Storage** - Cache API for small files (<5MB), IndexedDB for large videos (≥5MB)
10. **Cached Video Seeking** - Blob URLs from cache enable smooth reverse playback
11. **Neighbor Graph** - BFS traversal finds reachable pages (1 hop to reduce requests)
12. **Instant Video Overlay** - Container hidden while buffering, instant removal when bg ready (no fade)
13. **Smooth Crossfade** - 700ms CSS animation with Material Design easing for non-video navigation
14. **usePageSwitch** - Keeps previous background visible until new one is painted
14. **Post-Refresh Cache** - Assets stored in Cache API under storage key survive page refresh
---
## Troubleshooting
### Page Not Loading
1. Check runtime context detection
2. Verify project slug matches
3. Check X-Runtime-* headers sent
4. Verify environment filtering
### Transitions Not Playing
1. Check transition video URL valid
2. Verify supports_reverse for back navigation
3. Check useTransitionPlayback errors in console
4. Verify video preloaded via preloadOrchestrator
5. Check isBuffering state in overlay rendering
### Offline Mode Issues
1. Check Service Worker registered
2. Verify storage quota available
3. Check IndexedDB for cached assets
4. Test with DevTools offline mode
### Black Flash on Navigation (Fixed)
**Problem:** Black flashes appeared during page transitions at three points:
1. **At transition START** - Video not ready when overlay appears
2. **At transition END** - Background not ready when overlay removed
3. **On direct navigation** - No transition video to cover the switch
**Root Causes:**
- `isBuffering` only tracked reverse playback, not initial video loading
- CSS `background-image` vs `img.decode()` mismatch - decode worked on Image elements but background used CSS
- Overlay removed before new page fully painted
**Solution - 5-Phase Implementation:**
| Phase | Fix | Implementation |
|-------|-----|----------------|
| 1 | Use `phase` for overlay opacity | `opacity: transitionPhase === 'preparing' \|\| isBuffering ? 0 : 1` |
| 2 | Add Image element for background | Next.js `` with `onLoad={() => setIsBackgroundReady(true)}` |
| 3 | Keep transition frame until ready | `pendingTransitionComplete && isBackgroundReady` guards overlay removal |
| 4 | Fix direct navigation | `setIsBackgroundReady(false)` before page change |
| 5 | Video cleanup | `video.removeAttribute('src'); video.load()` prevents memory leaks |
**Timing Sequence:**
```
Navigation WITH transition:
1. User clicks → setTransitionPreview() → overlay renders (opacity: 0)
2. Video loads → phase: 'preparing' → 'playing' → overlay fades in
3. Video ends → onComplete fires → waitForPageImages() → page switches
4. Image onLoad → isBackgroundReady: true
5. Effect triggers → RAF × 2 → overlay removed
Navigation WITHOUT transition:
1. User clicks → waitForPageImages() → setIsBackgroundReady(false)
2. Page switches → Image onLoad → isBackgroundReady: true
3. New page visible immediately (no overlay needed)
```
**Key Files:**
- `RuntimePresentation.tsx:132` - Phase destructuring from useTransitionPlayback
- `RuntimePresentation.tsx:179-190` - useBackgroundTransition hook for fade-out effects
- `RuntimePresentation.tsx:444-483` - Background Image element with onLoad