39948-vm/documentation/project-transition-settings.md
2026-07-03 16:11:24 +02:00

586 lines
25 KiB
Markdown

# 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<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`
```typescript
// 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`
```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<ElementTransitionSettings | null>(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)