25 KiB
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
GET /api/project-transition-settings/project/:projectId/env/:environment
# Production: No auth required (public)
# Dev/Stage: Authorization: Bearer {token}
Response (200):
{
"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
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
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:
// 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'sid - When copying stage → production: production record's
source_key= stage record'sid
Frontend Integration
Redux Store
File: frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts
interface ProjectTransitionSettingsState {
byProjectEnv: Record<string, ProjectTransitionSettingsEntity | null>;
loading: boolean;
loadingKeys: Record<string, boolean>;
}
// 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
// Simple fetch - backend GET is always public
export const fetch = createAsyncThunk<GlobalTransitionDefaults, void>(
'global_transition_defaults/fetch',
async () => {
const result = await axios.get('global-transition-defaults');
return result.data;
}
);
Type Definitions
File: frontend/src/types/transition.ts
// 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:
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
// 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
// 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:
// State for current element's transition settings
const [currentElementTransitionSettings, setCurrentElementTransitionSettings] =
useState<ElementTransitionSettings | null>(null);
Extracting settings from clicked elements:
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
transitionTypeandtransitionEasing
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:
setCurrentElementTransitionSettings(elementSettings)is calledswitchToPage()starts the transitionuseTransitionSettingshook 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:
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.tsxfrontend/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:
// 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:
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
- Verify settings exist:
GET /api/project-transition-settings/project/:id/env/:environment - Check cascade order - element settings override project settings
- Ensure correct environment is being queried (dev/stage/production)
Settings Not Publishing
- Check publish event completed successfully
- Verify source environment has settings before publish
- Check
source_keyin target environment record
Missing Settings After Project Creation
New projects don't automatically get project-level settings. They use:
- Global defaults (if configured)
- 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)