# Project Transition Settings Documentation for the environment-aware project-level transition settings system that cascades with global and element-level settings. ## Overview Project transition settings control the default CSS-based transition behavior for page navigation when no video transition is configured on an element. Settings are stored per-project and per-environment, following the same publishing pattern as `tour_pages` and `project_audio_tracks`. ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Transition Settings Cascade │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Element Level (highest priority) ││ │ │ └── ui_schema_json.elements[].transitionSettings ││ │ └─────────────────────────────────────────────────────────────────────┘│ │ ↓ fallback │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Project Level (per environment) ││ │ │ └── project_transition_settings WHERE projectId AND environment ││ │ └─────────────────────────────────────────────────────────────────────┘│ │ ↓ fallback │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Global Level (platform-wide defaults) ││ │ │ └── global_transition_defaults (single record) ││ │ └─────────────────────────────────────────────────────────────────────┘│ │ ↓ fallback │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ Hardcoded Fallback ││ │ │ └── type: 'fade', durationMs: 700, easing: 'ease-in-out' ││ │ └─────────────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────────────┘ ``` ## Environment-Aware Architecture Project transition settings follow the same environment isolation pattern as tour pages: ``` Publishing Flow: ┌─────────────────────────────────────────────────────────────────┐ │ Constructor (dev) │ │ └── project_transition_settings WHERE environment='dev' │ │ ↓ │ │ [Save to Stage] │ │ ↓ │ │ Stage (Preview) │ │ └── project_transition_settings WHERE environment='stage' │ │ ↓ │ │ [Publish] │ │ ↓ │ │ Production (Public) │ │ └── project_transition_settings WHERE environment='production' │ └─────────────────────────────────────────────────────────────────┘ ``` ## Database Schema ### Table: `project_transition_settings` | Column | Type | Nullable | Default | Description | |--------|------|----------|---------|-------------| | id | UUID | No | UUIDV4 | Primary key | | projectId | UUID | No | - | FK to projects (CASCADE delete) | | environment | ENUM | No | - | 'dev', 'stage', 'production' | | source_key | TEXT | Yes | null | Tracks source record during publishing | | transition_type | TEXT | No | 'fade' | CSS transition type ('fade', 'none') | | duration_ms | INTEGER | No | 700 | Transition duration in milliseconds | | easing | TEXT | No | 'ease-in-out' | CSS easing function | | overlay_color | TEXT | No | '#000000' | Color of transition overlay | | createdById | UUID | Yes | - | FK to users | | updatedById | UUID | Yes | - | FK to users | | createdAt | TIMESTAMP | No | NOW() | Creation timestamp | | updatedAt | TIMESTAMP | No | NOW() | Last update timestamp | | deletedAt | TIMESTAMP | Yes | null | Soft delete timestamp | **Indexes:** - `project_transition_settings_project_env_unique` - UNIQUE on (projectId, environment) WHERE deletedAt IS NULL **Constraints:** - Only one active record per project/environment combination - Soft delete (paranoid: true) ## API Endpoints ### Authentication Model The API uses **URL-path-based public access** for runtime presentations: | Endpoint | Method | Environment | Auth Required | |----------|--------|-------------|---------------| | `/project/:id/env/production` | GET | production | **No** (public) | | `/project/:id/env/dev` | GET | dev | Yes | | `/project/:id/env/stage` | GET | stage | Yes | | `/project/:id/env/*` | PUT/DELETE | any | Yes | | Standard CRUD (`/`, `/:id`) | all | n/a | Yes | This allows public presentations (`/p/[slug]`) to fetch production transition settings without authentication, while protecting dev/stage environments and write operations. Write operations use the `PAGE_ELEMENTS` permission family because transition defaults are authored as part of the tour page/element editing surface. Global defaults and project environment upserts require `UPDATE_PAGE_ELEMENTS`. Environment-level reset uses `DELETE` to remove the project override row, but authorization treats it as an update and also requires `UPDATE_PAGE_ELEMENTS` rather than `DELETE_PAGE_ELEMENTS`. ### Get Settings by Project and Environment ```http GET /api/project-transition-settings/project/:projectId/env/:environment # Production: No auth required (public) # Dev/Stage: Authorization: Bearer {token} ``` **Response (200):** ```json { "id": "uuid", "projectId": "project-uuid", "environment": "dev", "transition_type": "fade", "duration_ms": 700, "easing": "ease-in-out", "overlay_color": "#000000", "createdAt": "2026-05-01T12:00:00Z", "updatedAt": "2026-05-01T12:00:00Z" } ``` **Response (200) when no settings exist:** `null` (use global defaults) **Response (401):** Authentication required (for dev/stage without JWT) ### Create or Update Settings ```http PUT /api/project-transition-settings/project/:projectId/env/:environment Authorization: Bearer {token} Content-Type: application/json { "transition_type": "fade", "duration_ms": 1000, "easing": "ease-out", "overlay_color": "#1a1a1a" } ``` **Response (200):** Created or updated settings object ### Delete Settings ```http DELETE /api/project-transition-settings/project/:projectId/env/:environment Authorization: Bearer {token} ``` **Response (200):** `{ "success": true }` ### Standard CRUD Endpoints (All Require Auth) ``` GET /api/project-transition-settings - List all GET /api/project-transition-settings/:id - Get by ID POST /api/project-transition-settings - Create PUT /api/project-transition-settings/:id - Update DELETE /api/project-transition-settings/:id - Delete ``` ## Publishing Integration ### Copy During Save to Stage / Publish Project transition settings are copied along with tour pages and audio tracks: ```javascript // In PublishService.copyEnvironment() const [sourcePages, sourceAudioTracks, sourceTransitionSettings] = await Promise.all([ db.tour_pages.findAll({ where: { projectId, environment: fromEnv }, transaction }), db.project_audio_tracks.findAll({ where: { projectId, environment: fromEnv }, transaction }), db.project_transition_settings.findOne({ where: { projectId, environment: fromEnv }, transaction }), ]); // Clean up target environment await Promise.all([ db.tour_pages.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }), db.project_audio_tracks.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }), db.project_transition_settings.destroy({ where: { projectId, environment: toEnv }, transaction, force: true }), ]); // Copy transition settings if exists if (sourceTransitionSettings) { await db.project_transition_settings.create({ ...sanitizedData, projectId, environment: toEnv, source_key: sourceTransitionSettings.id, createdById: actorId, updatedById: actorId, }, { transaction }); } ``` ### Source Key Tracking The `source_key` field tracks the lineage of copied records: - When copying dev → stage: stage record's `source_key` = dev record's `id` - When copying stage → production: production record's `source_key` = stage record's `id` ## Frontend Integration ### Redux Store **File:** `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` ```typescript interface ProjectTransitionSettingsState { byProjectEnv: Record; loading: boolean; loadingKeys: Record; } // Actions - no special headers needed, backend determines public access from URL path fetchByProjectAndEnv({ projectId, environment }) upsertByProjectAndEnv({ projectId, environment, data }) deleteByProjectAndEnv({ projectId, environment }) // Selectors selectByProjectAndEnv(state, projectId, environment) selectTransitionSettingsLoading(state, projectId, environment) ``` **Global Transition Defaults Store** **File:** `frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts` ```typescript // Simple fetch - backend GET is always public export const fetch = createAsyncThunk( 'global_transition_defaults/fetch', async () => { const result = await axios.get('global-transition-defaults'); return result.data; } ); ``` ### Type Definitions **File:** `frontend/src/types/transition.ts` ```typescript // Database entity (snake_case) interface ProjectTransitionSettingsEntity extends BaseEntity { projectId: string; environment: 'dev' | 'stage' | 'production'; source_key?: string; transition_type: TransitionType; duration_ms: number; easing: EasingFunction; overlay_color: string; } // Cascade resolution format (camelCase) interface ProjectTransitionSettings { transitionType?: TransitionType; durationMs?: number; easing?: EasingFunction; overlayColor?: string; } // Convert entity to cascade format function entityToProjectSettings(entity): ProjectTransitionSettings | null ``` ### Hook: useTransitionSettings **File:** `frontend/src/hooks/useTransitionSettings.ts` Resolves final transition settings through the cascade: ```typescript const transitionSettings = useTransitionSettings({ globalDefaults, // From global_transition_defaults table projectSettings, // From project_transition_settings (converted via entityToProjectSettings) elementSettings, // From element's ui_schema_json }); // Returns: ResolvedTransitionSettings // { type, durationMs, easing, overlayColor, videoUrl?, reverseVideoUrl? } ``` ### Constructor Usage **File:** `frontend/src/pages/constructor.tsx` ```typescript // Fetch dev environment settings useEffect(() => { if (projectId) { dispatch(fetchProjectTransitionSettings({ projectId, environment: 'dev' })); } }, [dispatch, projectId]); // Select from store const projectTransitionSettingsEntity = useAppSelector((state) => projectId ? selectProjectTransitionSettings(state, projectId, 'dev') : undefined ); // Convert for cascade resolution const projectTransitionSettings = useMemo( () => entityToProjectSettings(projectTransitionSettingsEntity), [projectTransitionSettingsEntity] ); ``` ### Runtime Presentation Usage **File:** `frontend/src/components/RuntimePresentation.tsx` ```typescript // Fetch environment-specific settings useEffect(() => { if (project?.id) { dispatch(fetchProjectTransitionSettings({ projectId: project.id, environment })); } }, [dispatch, project?.id, environment]); // Use in transition settings resolution const transitionSettings = useTransitionSettings({ globalDefaults, projectSettings, // Environment-specific elementSettings: currentElementTransitionSettings, // From React state }); ``` ### Element Settings State Tracking Both `constructor.tsx` and `RuntimePresentation.tsx` track element-level transition settings via React state: ```typescript // State for current element's transition settings const [currentElementTransitionSettings, setCurrentElementTransitionSettings] = useState(null); ``` **Extracting settings from clicked elements:** ```typescript import { extractElementTransitionSettings } from '../types/transition'; // In click handler, extract settings from the navigation element const elementSettings = extractElementTransitionSettings(clickedElement); ``` The `extractElementTransitionSettings()` function converts element fields to `ElementTransitionSettings` format: - Only includes fields with actual values (not empty strings) - Allows cascade to fall through when element uses "Use Project Default" - Handles type coercion for `transitionType` and `transitionEasing` ### Critical: React Timing Fix with flushSync **Problem:** React's async state batching can cause transitions to start with OLD settings values. When a user clicks a navigation button, this sequence happens: 1. `setCurrentElementTransitionSettings(elementSettings)` is called 2. `switchToPage()` starts the transition 3. `useTransitionSettings` hook resolves final settings Without synchronization, step 2 can execute before React processes step 1, causing the transition to use stale settings. **Solution:** Use `flushSync` from `react-dom` to force synchronous state updates: ```typescript import { flushSync } from 'react-dom'; // In handleElementClick: const elementSettings = extractElementTransitionSettings(clickedElement); // Force synchronous state update BEFORE navigation starts flushSync(() => { setCurrentElementTransitionSettings(elementSettings); }); // Now transition will use the correct settings switchToPage(targetPageId, transitionConfig); ``` **Files affected:** - `frontend/src/pages/constructor.tsx` - `frontend/src/components/RuntimePresentation.tsx` ### TourFlowManager (Settings UI) **File:** `frontend/src/components/TourFlowManager.tsx` The TourFlowManager component provides a UI for editing project transition settings in the dev environment: ```typescript // Fetch and display current settings const projectTransitionSettingsEntity = useAppSelector((state) => selectedProjectId ? selectByProjectAndEnv(state, selectedProjectId, 'dev') : undefined ); // Save changes const handleSaveTransitionSettings = async () => { await dispatch(upsertByProjectAndEnv({ projectId: selectedProjectId, environment: 'dev', data: { transition_type: localTransitionType, duration_ms: localDurationMs, easing: localEasing, overlay_color: localOverlayColor, }, })); }; ``` ## Transition Types | Type | CSS Behavior | Description | |------|--------------|-------------| | `fade` | opacity 0→1 | Smooth crossfade between pages | | `none` | No animation | Instant page switch | ## Easing Functions | Value | CSS equivalent | Description | |-------|----------------|-------------| | `linear` | linear | Constant speed | | `ease` | ease | Standard easing | | `ease-in` | ease-in | Slow start | | `ease-out` | ease-out | Slow end | | `ease-in-out` | ease-in-out | Slow start and end | ## Video Transition Priority When an element has a `transitionVideoUrl`, the video transition **always takes precedence** over CSS-based transitions: ```typescript if (elementSettings?.transitionVideoUrl) { return { type: 'video', durationMs: elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs, easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing, overlayColor: /* cascade through element → project → global */, videoUrl: elementSettings.transitionVideoUrl, reverseVideoUrl: elementSettings.reverseVideoUrl, }; } ``` ## File References | File | Purpose | |------|---------| | `backend/src/db/migrations/20260501000002-create-project-transition-settings.js` | Database migration | | `backend/src/db/models/project_transition_settings.js` | Sequelize model | | `backend/src/db/api/project_transition_settings.ts` | Database API layer | | `backend/src/services/project_transition_settings.ts` | Service layer | | `backend/src/routes/project_transition_settings.ts` | REST endpoints | | `backend/src/services/publish.ts` | Publishing integration | | `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store | | `frontend/src/types/transition.ts` | TypeScript definitions | | `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution hook | | `frontend/src/components/TourFlowManager.tsx` | Settings UI | | `frontend/src/pages/constructor.tsx` | Constructor integration | | `frontend/src/components/RuntimePresentation.tsx` | Runtime integration | ## Troubleshooting ### Settings Not Applying 1. Verify settings exist: `GET /api/project-transition-settings/project/:id/env/:environment` 2. Check cascade order - element settings override project settings 3. Ensure correct environment is being queried (dev/stage/production) ### Settings Not Publishing 1. Check publish event completed successfully 2. Verify source environment has settings before publish 3. Check `source_key` in target environment record ### Missing Settings After Project Creation New projects don't automatically get project-level settings. They use: 1. Global defaults (if configured) 2. Hardcoded fallback (fade, 700ms, ease-in-out) To set project defaults, use TourFlowManager or API. --- ## Slide Transitions (Gallery/Carousel) Slide transitions for Gallery and Carousel elements cascade from page transition settings, with optional element-level overrides. ### Cascade Flow ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Slide Transition Cascade │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Element Override (highest priority) │ │ │ │ └── gallerySlideTransition* / carouselSlideTransition* fields │ │ │ └────────────────────────────┬─────────────────────────────────────┘ │ │ ↓ fallback │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Page Transition Settings (resolved via useTransitionSettings) │ │ │ │ └── Global → Project cascade (see above) │ │ │ └────────────────────────────┬─────────────────────────────────────┘ │ │ ↓ fallback │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Hardcoded Fallback │ │ │ │ └── type: 'fade', durationMs: 700, easing: 'ease-in-out', │ │ │ │ overlayColor: '#000000' │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Element Override Fields Stored in `tour_pages.ui_schema_json.elements[]`: **Carousel:** | Field | Type | Description | |-------|------|-------------| | `carouselSlideTransitionType` | `'fade' \| 'none' \| ''` | Transition type ('' = use default) | | `carouselSlideTransitionDurationMs` | `number \| ''` | Duration in ms | | `carouselSlideTransitionEasing` | `EasingFunction \| ''` | CSS easing | | `carouselSlideTransitionOverlayColor` | `string` | Overlay color (hex) | **Gallery:** | Field | Type | Description | |-------|------|-------------| | `gallerySlideTransitionType` | `'fade' \| 'none' \| ''` | Transition type ('' = use default) | | `gallerySlideTransitionDurationMs` | `number \| ''` | Duration in ms | | `gallerySlideTransitionEasing` | `EasingFunction \| ''` | CSS easing | | `gallerySlideTransitionOverlayColor` | `string` | Overlay color (hex) | ### Slide vs Page Transition Differences | Aspect | Page Transitions | Slide Transitions | |--------|------------------|-------------------| | Video type | Supported | Maps to 'fade' (no video between slides) | | Overlay color | Yes (fade through overlay) | Yes (same behavior) | | Duration | Configurable | Inherits from page or element override | | Element override | Navigation element | Gallery/Carousel element | ### Implementation Files | File | Purpose | |------|---------| | `frontend/src/lib/resolveSlideTransition.ts` | Cascade resolver + extractors | | `frontend/src/hooks/useSlideTransition.ts` | Animation state management hook | | `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Carousel with crossfade | | `frontend/src/components/UiElements/GalleryCarouselOverlay.tsx` | Gallery/Info Panel fullscreen media overlay with crossfade | | `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Element editor UI | ### Animation Mechanism The `useSlideTransition` hook implements fade-through-overlay animation: ``` Phase 1: Fade Out (half duration) ├── overlayOpacity: 0 → 1 └── slideOpacity: 1 → 0 Phase 2: Swap (at midpoint) └── displayIndex switches to new slide Phase 3: Fade In (half duration) ├── overlayOpacity: 1 → 0 └── slideOpacity: 0 → 1 ``` ### Backwards Compatibility Existing elements without slide transition fields automatically cascade to page transition settings or hardcoded fallbacks. No database migration required - fields are stored in JSON column. **Behavior for existing projects:** - Elements with page transitions configured → inherits those settings - Elements with no page transitions → uses hardcoded fallback (fade, 700ms)