```
**Key Properties:**
| Property | Type | Description |
|----------|------|-------------|
| `iconUrl` | string | Custom button icon image |
| `navLabel` | string | Text label (default: "Next"/"Back") |
| `navLabelFontFamily` | string | Font key for label text (e.g., 'instrument-sans-condensed') |
| `navType` | 'forward' \| 'back' | Direction for transition logic |
| `navDisabled` | boolean | Disable navigation |
| `targetPageSlug` | string | Destination page slug (consistent across environments) |
| `transitionVideoUrl` | string | Video to play during transition |
| `transitionReverseMode` | 'auto_reverse' \| 'separate_video' | Reverse playback mode |
| `reverseVideoUrl` | string | Separate video for back navigation |
| `transitionDurationSec` | number | Transition duration (default: 0.7) |
### Hotspot (spot)
**Purpose:** Interactive clickable area for triggering actions.
**Interactive Behavior:**
- **Click:** Triggers navigation or custom action
- **Hover:** Can display tooltip or highlight (CSS-based)
**Runtime Rendering:** Uses `iconUrl` or `imageUrl` if provided, otherwise transparent clickable area.
### Description
**Purpose:** Display formatted text content with title and body.
**Interactive Behavior:**
- **Static:** No default interactivity
- **Click:** Can trigger navigation if `targetPageSlug` set
**Runtime Rendering:**
```tsx
// With icon
// Without icon (text block)
// Note: Background color is applied to the wrapper via CSS Styles (backgroundColor)
{descriptionTitle}
{descriptionText}
```
**Key Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `descriptionTitle` | string | "TITLE" | Title text |
| `descriptionText` | string | "" | Body text |
| `descriptionTitleFontSize` | string | "48px" | Title font size |
| `descriptionTextFontSize` | string | "36px" | Body font size |
| `descriptionTitleFontFamily` | string | "inherit" | Title font family |
| `descriptionTextFontFamily` | string | "inherit" | Body font family |
| `descriptionTitleColor` | string | "#000000" | Title color |
| `descriptionTextColor` | string | "#4B5563" | Body color |
**Note:** Background color is controlled via the CSS Styles tab (`backgroundColor` property) rather than a separate description-specific field.
### Tooltip (Deprecated)
> **Note:** The Tooltip element type has been deprecated and removed from the frontend constructor. Existing tooltip elements may still render in runtime, but new ones cannot be created. Backend defaults remain for backward compatibility.
**Purpose:** Display contextual information on hover/click.
**Interactive Behavior:**
- **Hover/Click:** Typically shows expanded content (implementation-dependent)
- **Display:** Compact card with title and text
**Key Properties:**
| Property | Type | Description |
|----------|------|-------------|
| `iconUrl` | string | Trigger icon image |
| `tooltipTitle` | string | Tooltip header text |
| `tooltipText` | string | Tooltip body text |
### Gallery
**Purpose:** Display a grid of images with optional titles and descriptions.
**Interactive Behavior:**
- **Click on card:** Can open lightbox or navigate (implementation-dependent)
- **Layout:** 3-column grid by default
**Runtime Rendering:**
```tsx
{galleryCards.map((card) => (
))}
```
**Key Properties:**
| Property | Type | Description |
|----------|------|-------------|
| `galleryCards` | GalleryCard[] | Array of gallery items |
**GalleryCard Structure:**
```typescript
{
id: string;
imageUrl: string;
title: string;
description: string;
}
```
**Gallery Section Dimension Properties:**
The CSS tab provides dimension controls for gallery sections:
| Section | Property | Default | Description |
|---------|----------|---------|-------------|
| Header | `galleryHeaderWidth` | (none) | Header container width (e.g., "100%", "300px") |
| Header | `galleryHeaderHeight` | `auto` | Header/image height |
| Header | `galleryHeaderMinHeight` | (none) | Minimum header height |
| Header | `galleryHeaderMaxHeight` | (none) | Maximum header height |
| Cards | `galleryCardAspectRatio` | `4/3` | Card aspect ratio (4:3, 16:9, 1:1, 3:2, auto) |
| Cards | `galleryCardMinHeight` | `40px` | Minimum card height |
| Cards | `galleryCardWidth` | (none) | Card width (grid controls by default) |
| Cards | `galleryCardHeight` | (none) | Card height (aspect ratio controls by default) |
**Gallery Section Text Alignment:**
Text alignment can be configured for each gallery section (header, title, spans):
| Section | Property | Default | Values |
|---------|----------|---------|--------|
| Header | `galleryHeaderTextAlign` | `center` | `left`, `center`, `right` |
| Title | `galleryTitleTextAlign` | `center` | `left`, `center`, `right` |
| Info Spans | `gallerySpanTextAlign` | `center` | `left`, `center`, `right` |
**Slide Transition Override Properties (Fullscreen Carousel Overlay):**
When a gallery card is clicked, a fullscreen carousel overlay opens. These properties control slide transitions within that overlay:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `gallerySlideTransitionType` | `'fade' \| 'none' \| ''` | `''` | Transition type ('' = inherit from page transitions) |
| `gallerySlideTransitionDurationMs` | `number \| ''` | `''` | Duration in ms ('' = inherit) |
| `gallerySlideTransitionEasing` | `EasingFunction \| ''` | `''` | CSS easing function |
| `gallerySlideTransitionOverlayColor` | `string` | `''` | Overlay color for fade ('' = inherit) |
> **See:** [Project Transition Settings - Slide Transitions](project-transition-settings.md#slide-transitions-gallerycarousel) for cascade behavior.
### Carousel
**Purpose:** Image slideshow with navigation controls. Supports two modes:
- **Normal mode:** Inline carousel within element dimensions
- **Full-width mode:** Covers full viewport as background layer (z-10), other elements positioned above
**Interactive Behavior:**
- **Prev/Next buttons:** Navigate between slides (click)
- **Arrow keys:** ArrowLeft/ArrowRight navigation (full-width mode, runtime only)
- **Touch swipe:** Swipe left/right to navigate (full-width mode, runtime only)
- **Draggable buttons:** Drag to reposition nav buttons (full-width mode, constructor only)
**Runtime Rendering:**
```tsx
// Normal mode: inline carousel
{/* Navigation buttons at fixed positions */}
// Full-width mode: portal-based layers
// Background layer (z-10): Full-screen image
// Controls layer (z-30): Navigation buttons at configurable positions
```
**Key Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `carouselSlides` | CarouselSlide[] | [] | Array of slide items |
| `carouselPrevIconUrl` | string | '' | Previous button custom icon |
| `carouselNextIconUrl` | string | '' | Next button custom icon |
| `carouselCaptionFontFamily` | string | '' | Font key for captions |
| `carouselFullWidth` | boolean | false | Enable full-width background mode |
**Full-Width Mode Button Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `carouselPrevX` | number | 5 | Prev button X position (% from left) |
| `carouselPrevY` | number | 50 | Prev button Y position (% from top) |
| `carouselNextX` | number | 95 | Next button X position (% from left) |
| `carouselNextY` | number | 50 | Next button Y position (% from top) |
| `carouselPrevWidth` | string | '' | Prev button width (vw units) |
| `carouselPrevHeight` | string | '' | Prev button height (vh units) |
| `carouselNextWidth` | string | '' | Next button width (vw units) |
| `carouselNextHeight` | string | '' | Next button height (vh units) |
**Button Rendering Modes:**
| Condition | Rendering Style |
|-----------|-----------------|
| No custom icon | Default MDI chevron icon with backdrop blur |
| Custom icon, no dimensions | Fixed 40x40 icon |
| Custom icon + dimensions | Navigation-style: icon fills button, no backdrop |
**CarouselSlide Structure:**
```typescript
{
id: string;
imageUrl: string;
caption: string;
}
```
**Slide Transition Override Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `carouselSlideTransitionType` | `'fade' \| 'none' \| ''` | `''` | Transition type ('' = inherit from page transitions) |
| `carouselSlideTransitionDurationMs` | `number \| ''` | `''` | Duration in ms ('' = inherit) |
| `carouselSlideTransitionEasing` | `EasingFunction \| ''` | `''` | CSS easing function |
| `carouselSlideTransitionOverlayColor` | `string` | `''` | Overlay color for fade ('' = inherit) |
> **See:** [Project Transition Settings - Slide Transitions](project-transition-settings.md#slide-transitions-gallerycarousel) for cascade behavior.
**Full-Width Mode Architecture:**
```
CarouselElement (full-width mode)
├── Background layer (z-10): Fixed inset-0, image display
├── Controls layer (z-30): Navigation buttons, caption
│ └── Buttons have:
│ - Configurable position (percentage-based)
│ - Configurable dimensions (vw/vh units)
│ - Draggable in constructor edit mode
│ - Navigation-style rendering when icon+dimensions set
└── Edit mode placeholder: Clickable element for selection
```
### Video Player
**Purpose:** Embedded video playback with HTML5 controls.
**Interactive Behavior:**
- **Controls:** Play, pause, seek, volume, fullscreen
- **Autoplay:** Starts automatically if `mediaAutoplay` is true
- **Loop:** Repeats continuously if `mediaLoop` is true
**Runtime Rendering:**
```tsx
```
**Key Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `mediaUrl` | string | "" | Video source URL |
| `mediaAutoplay` | boolean | true | Auto-start playback |
| `mediaLoop` | boolean | true | Loop continuously |
| `mediaMuted` | boolean | true | Start muted |
### Audio Player
**Purpose:** Embedded audio playback with HTML5 controls.
**Interactive Behavior:**
- **Controls:** Play, pause, seek, volume
- **Autoplay:** Starts automatically if `mediaAutoplay` is true
**Runtime Rendering:**
```tsx
```
**Key Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `mediaUrl` | string | "" | Audio source URL |
| `mediaAutoplay` | boolean | true | Auto-start playback |
| `mediaLoop` | boolean | true | Loop continuously |
| `mediaMuted` | boolean | false | Start muted (note: false by default) |
### Logo
**Purpose:** Display branding/logo image.
**Interactive Behavior:**
- **Static:** Typically non-interactive
- **Click:** Can navigate to homepage or external URL
**Runtime Rendering:** Uses `iconUrl` or `backgroundImageUrl` with `object-contain` scaling.
### Popup
**Purpose:** Modal dialog or overlay content.
**Interactive Behavior:**
- **Trigger:** Opened by element click or programmatic trigger
- **Close:** Close button or click outside
### Info Panel (info_panel)
**Purpose:** Interactive panel with multiple sections for displaying images, 360° embeds, text content, and info spans. Similar to Gallery but with more flexible section-based layout and support for embedding external 360° viewers.
**Architecture:**
The Info Panel consists of three independently positioned components:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Info Panel Architecture │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Trigger Button │ │ Info Panel │ │ Image Detail │ │
│ │ │ │ (Overlay) │ │ Panel │ │
│ │ • xPercent │ │ │ │ │ │
│ │ • yPercent │───▶│ • panelXPercent │───▶│ • detailXPercent │ │
│ │ • iconUrl │ │ • panelYPercent │ │ • detailYPercent │ │
│ │ • hoverReveal │ │ • sections[] │ │ • 360° embeds │ │
│ │ │ │ • spans, images │ │ • captions │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ Click Opens panel overlay Card/360 click opens │
└─────────────────────────────────────────────────────────────────────────┘
```
**Interactive Behavior:**
- **Trigger Click:** Opens the Info Panel overlay with backdrop
- **Disabled:** `infoPanelDisabled: true` suppresses panel opening in constructor interact mode and runtime presentations; runtime hover/focus/active visuals are preserved
- **Default State:** `infoPanelOpenByDefault: true` opens every enabled matching Info Panel automatically when the page renders in constructor interact mode or runtime presentations
- **Multiple Open Panels:** Runtime and constructor interact mode track open Info Panels by element ID. Image detail panels, fullscreen gallery state, and runtime media-section selected image state are scoped to the originating Info Panel ID, so multiple default-open panels can coexist without sharing detail/gallery state. Only one shared fullscreen backdrop is rendered for the group; backdrop clicks close the whole group, while each panel close button closes only that panel.
- **Constructor Edit Preview:** In edit mode the Info Panel overlay is rendered
only from the selected Info Panel element, not from runtime `activeInfoPanel`
state. The Info Panel and Image Detail preview surfaces use non-blocking
pointer events so canvas elements remain selectable/draggable; only the
overlay drag handles stay interactive for panel positioning.
- **Panel Backdrop Click:** Closes the panel
- **Card/Thumbnail Click:** Uses section-level `mediaOpenMode`: `panel` opens the item in the side preview panel, and `fullscreen` opens the section's image/video/360 items in the shared fullscreen gallery overlay. Per-item `clickAction` can override this with `target_page` or `external_url`.
- **Use as Screen Background:** Media items with `useAsBackground` replace the current page background with their image, video, or 360 embed content. This uses the same unified background renderer as `tour_pages.background_image_url`, `background_video_url`, and `background_embed_url`; destination override controls are disabled while this checkbox is enabled.
- **Video Thumbnail Click:** Video items use `videoUrl`; Info Panel thumbnails render a muted first-frame video preview with a play badge instead of a generic placeholder, and open in the configured panel/fullscreen destination with native video controls
- **Media Item Ordering:** Info Panel media sections render `section.images` in stored order, mixing image/video/360 items without regrouping by media type, so the presentation order matches the constructor sidebar order.
- **360° Trigger Click:** Uses the same section-level `mediaOpenMode`; `panel` opens the 360 embed in the side preview panel and `fullscreen` opens it in the fullscreen gallery overlay without rendering runtime top-right controls or granting iframe fullscreen permission
- **360° Embed Chrome:** 360 iframe URLs are normalized through
`buildChromeFreeEmbedUrl` before rendering. For Kuula embeds, internal viewer
chrome that duplicates platform controls is disabled with `fs=0` while
preserving playback/autorotate parameters from the source URL.
- **Header/Title/Text/Span Click:** Can target an internal page or an external URL when configured
- **Hover Reveal:** Trigger can use `hoverReveal` effect (appears on hover)
- **Persist on Click:** With `hoverPersistOnClick`, trigger stays visible after click
**Section Types:**
The Info Panel uses a section-based layout with dynamic ordering. Each section instance has its own ID, settings, and data:
| Section | Description | Per-Instance Data |
|---------|-------------|-------------------|
| `header` | Image header with text overlay | `headerImageUrl`, `headerText` |
| `title` | Panel title text | `title` |
| `text` | Body text content | `text` |
| `spans` | Info badges grid | `spans[]`, `columns`, `gap` |
| `cards` | Image cards grid | `images[]`, `columns`, `gap` |
| `images` | Media viewer | `images[]` |
`columns` is normalized on load from number-like values, including strings such as `"3"`, and clamped to the supported `1..6` range before rendering.
**Click Destinations:**
| Scope | Supported Destinations | Fields |
|-------|------------------------|--------|
| Cards/media section | `panel`, `fullscreen` | `mediaOpenMode` |
| Image/card/video/360 item override | `target_page`, `external_url`, screen background | `clickAction`, `targetPageSlug`, `externalUrl`, `imageUrl`, `videoUrl`, `embedUrl`, `useAsBackground` |
| Header/title/text section | `target_page`, `external_url` | `clickAction`, `targetPageSlug`, `externalUrl` |
| Span item | `target_page`, `external_url` | `clickAction`, `targetPageSlug`, `externalUrl` |
Internal Info Panel links use `targetPageSlug` and are included in `extractPageLinksAndElements`, so connected pages participate in neighbor preloading. Info Panel image/video/icon assets nested inside `infoPanelSections` are also recursively extracted for preloading.
**Multiple Section Instances:**
Unlike Gallery, Info Panel allows multiple instances of the same section type:
```typescript
infoPanelSections: [
{ id: 'section-1', type: 'spans', columns: 3, gap: '8', spans: [...] },
{ id: 'section-2', type: 'spans', columns: 2, gap: '12', spans: [...] }, // Second spans
{ id: 'section-3', type: 'images', images: [...] },
]
```
When a new `info_panel` element is created, default section templates are copied with fresh local IDs. This prevents multiple newly-added Info Panels from sharing the same template section IDs.
**Runtime Rendering:**
```tsx
// Trigger button (uses hover reveal from Effects)
// Panel overlay (positioned independently)
// Image detail panel (for cards and 360° embeds)
```
**Sizing parity:** `InfoPanelOverlay` and `ImageDetailPanel` must receive the same canvas CSS custom properties (`cssVars`, including `--cu`) and the same `letterboxStyles` in constructor and runtime. The image/360 detail panel uses canvas units for `detailWidth`, `detailHeight`, padding, border radius, and border width; missing `cssVars` causes constructor/runtime dimensions to diverge.
**Iframe fullscreen behavior:** `ImageDetailPanel` first tries the browser
Fullscreen API for the panel itself. If that is blocked because the
presentation is embedded, it tries to fullscreen the embedding iframe when the
parent is same-origin. For cross-origin parents, it posts a
`tour-builder:request-fullscreen` message so controlled wrapper pages can
fullscreen the iframe element:
```html
```
If the browser still blocks parent fullscreen, the panel falls back to a local
fullscreen state that fills the iframe viewport. Esc exits this local mode
before closing the panel.
When the whole presentation is already in browser fullscreen, image detail
fullscreen uses local panel expansion instead of exiting the presentation's
fullscreen element. Toggling the image detail fullscreen button returns to the
configured side/detail panel view while keeping the presentation fullscreen.
`ImageDetailPanel` renders through a body-level portal so it is not trapped by
the canvas stacking context (`z-[46]`). In fullscreen image detail mode, the
detail wrapper is raised above runtime global controls so the fullscreen image
controls stay on top; normal detail panel mode keeps the lower shared overlay
stacking order.
**Key Properties:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `infoPanelTriggerLabel` | string | - | Label text for trigger button |
| `infoPanelTriggerFontFamily` | string | - | Font for trigger label |
| `infoPanelDisabled` | boolean | false | Disable panel opening |
| `infoPanelOpenByDefault` | boolean | false | Open this panel automatically when the page renders |
| `panelXPercent` | number | 30 | Panel X position (0-100) |
| `panelYPercent` | number | 50 | Panel Y position (0-100) |
| `panelWidth` | string | '400' | Panel width |
| `panelHeight` | string | 'auto' | Panel height |
| `panelBackgroundColor` | string | 'rgba(0,0,0,0.85)' | Panel background |
| `panelOverlayColor` | string | 'rgba(0,0,0,0.3)' | Backdrop overlay |
| `infoPanelSections` | array | DEFAULT_SECTIONS | Section instances |
| `detailXPercent` | number | 70 | Detail panel X position |
| `detailYPercent` | number | 50 | Detail panel Y position |
| `detailWidth` | string | '500' | Detail panel width |
| `detailHeight` | string | '400' | Detail panel height |
**Section Styling Properties:**
| Section | Style Properties |
|---------|------------------|
| Header | `infoPanelHeader[Color|FontSize|FontFamily|FontWeight|Padding|BorderRadius|TextAlign|Width|Height|MinHeight|MaxHeight]` |
| Title | `panelTitle[Color|FontSize|FontFamily]`, `infoPanelTitle[BackgroundColor|Padding|FontWeight|TextAlign]` |
| Text | `panelText[Color|FontSize|FontFamily]`, `infoPanelText[BackgroundColor|Padding|FontWeight|TextAlign]` |
| Spans | `infoPanelSpan[BackgroundColor|Color|FontFamily|FontSize|FontWeight|Padding|BorderRadius|Gap|TextAlign]` |
| Cards | `infoPanelCard[BackgroundColor|BorderRadius|AspectRatio|MinHeight|Gap]`, `infoPanelCardTitle[BackgroundColor|Color|FontFamily|FontSize|FontWeight|Padding]` |
## Constructor vs Runtime Rendering
| Aspect | Constructor | Runtime |
|--------|-------------|---------|
| **Positioning** | Drag-and-drop, resize handles | Fixed position via `xPercent`, `yPercent` |
| **Editing** | Property panels, inline editing | Read-only display |
| **Selection** | Click to select, multi-select | Click triggers actions |
| **Transitions** | Preview button | Full playback with video |
| **Appearance** | Visible at all times | Respects `appearDelaySec`, `appearDurationSec` |
| **Navigation** | Simulated (no page switch) | Actual page navigation |
### Unified Rendering Architecture
Both Constructor and Runtime use a shared rendering architecture for WYSIWYG consistency:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Shared Rendering Components │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ UiElementRenderer │ │
│ │ (Main entry point) │ │
│ │ │ │
│ │ ├── useElementWrapperStyle (shared styling hook) │ │
│ │ └── Per-type components: │ │
│ │ ├── NavigationElement ├── GalleryElement │ │
│ │ ├── InfoPanelElement ├── DescriptionElement │ │
│ │ ├── CarouselElement ├── LogoElement │ │
│ │ ├── SpotElement ├── VideoPlayerElement │ │
│ │ ├── AudioPlayerElement └── PopupElement │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ CanvasElement │ │ RuntimeElement │ │
│ │ (Constructor) │ │ (Runtime) │ │
│ │ │ │ │ │
│ │ • Position │ │ • Position │ │
│ │ • Selection │ │ • Effects │ │
│ │ • Drag-drop │ │ • Click events │ │
│ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
**Key Components:**
| Component | Location | Purpose |
|-----------|----------|---------|
| `UiElementRenderer` | `components/UiElements/UiElementRenderer.tsx` | Unified rendering entry point |
| `useElementWrapperStyle` | `components/UiElements/shared/useElementWrapperStyle.ts` | Consistent wrapper styling |
| `NavigationElement` | `components/UiElements/elements/NavigationElement.tsx` | Navigation button rendering |
| `GalleryElement` | `components/UiElements/elements/GalleryElement.tsx` | Gallery grid rendering |
| (other per-type) | `components/UiElements/elements/*.tsx` | Type-specific rendering |
| `CanvasElement` | `components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection) |
| `RuntimeElement` | `components/RuntimeElement.tsx` | Runtime wrapper (position, effects) |
**Benefits:**
- Single source of truth for element styling
- WYSIWYG consistency between constructor and runtime
- Per-type components enable focused maintenance
- Shared styling hook eliminates divergence
## Element Click Handler
**File:** `frontend/src/components/RuntimePresentation.tsx`
```typescript
const handleElementClick = useCallback(
(element: any) => {
if (element.targetPageSlug) {
// Resolve slug to page
const targetPage = pages.find(p => p.slug === element.targetPageSlug);
if (!targetPage) return;
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
const transitionVideoUrl = element.transitionVideoUrl;
navigateToPage(targetPage.id, transitionVideoUrl, isBack);
}
},
[navigateToPage, pages],
);
```
**Navigation Flow:**
1. Check if element has `targetPageSlug`
2. Resolve slug to page ID from loaded pages array
3. Determine direction (`isBack`) from `navType` or element type
4. If `transitionVideoUrl` exists, play transition overlay
5. Otherwise, wait for target page images to load
6. Switch to target page
7. Add to page history
## API Endpoints
### Global Scope (element_type_defaults)
**Route:** `backend/src/routes/element_type_defaults.ts`
Uses `checkCrudPermissions('element_type_defaults')`.
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| GET | `/api/element-type-defaults` | READ_PAGE_ELEMENTS | List all element types |
| GET | `/api/element-type-defaults/:id` | READ_PAGE_ELEMENTS | Get element type details |
| POST | `/api/element-type-defaults` | CREATE_PAGE_ELEMENTS | Create new type (admin) |
| PUT | `/api/element-type-defaults/:id` | UPDATE_PAGE_ELEMENTS | Update defaults |
| DELETE | `/api/element-type-defaults/:id` | DELETE_PAGE_ELEMENTS | Delete type (admin) |
| GET | `/api/element-type-defaults/autocomplete` | READ_PAGE_ELEMENTS | Autocomplete search |
| GET | `/api/element-type-defaults/count` | READ_PAGE_ELEMENTS | Count types |
**Backwards Compatibility:** The old route `/api/ui-elements` is aliased to `/api/element-type-defaults`.
**List Request:**
```http
GET /api/element-type-defaults?limit=1000&page=1&sort=asc&field=sort_order
```
**Update Request:**
```http
PUT /api/element-type-defaults/:id
{
"id": "uuid",
"data": {
"element_type": "navigation_next",
"name": "Navigation Forward Button",
"sort_order": 1,
"is_active": true,
"default_settings_json": {
"label": "Navigation: Forward",
"navLabel": "Forward",
"navType": "forward",
...
}
}
}
```
### Project Scope (project_element_defaults)
**Route:** `backend/src/routes/project_element_defaults.ts`
| Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------|
| GET | `/api/project-element-defaults` | READ_PAGE_ELEMENTS | List project defaults |
| GET | `/api/project-element-defaults/:id` | READ_PAGE_ELEMENTS | Get specific default |
| POST | `/api/project-element-defaults` | CREATE_PAGE_ELEMENTS | Create override |
| PUT | `/api/project-element-defaults/:id` | UPDATE_PAGE_ELEMENTS | Update override |
| DELETE | `/api/project-element-defaults/:id` | DELETE_PAGE_ELEMENTS | Delete override |
| POST | `/api/project-element-defaults/:id/reset` | UPDATE_PAGE_ELEMENTS | Reset to global default |
| GET | `/api/project-element-defaults/:id/diff` | READ_PAGE_ELEMENTS | Compare with global |
**Reset to Global Request:**
```http
POST /api/project-element-defaults/:id/reset
```
**Diff from Global Response:**
```json
{
"isDifferent": true,
"hasGlobalDefault": true,
"projectDefault": { "id": "...", "settings_json": { ... }, ... },
"globalDefault": { "id": "...", "default_settings_json": { ... }, ... }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `isDifferent` | boolean | Whether settings differ from global |
| `hasGlobalDefault` | boolean | Whether a matching global default exists |
| `projectDefault` | object | Full project default record |
| `globalDefault` | object \| null | Full global default record (or null if none) |
#### Project Element Defaults Details Page
**File:** `frontend/src/pages/project-element-defaults/[id].tsx`
**Permission:** `UPDATE_PAGE_ELEMENTS`
Uses the same shared `ElementSettings` components as the global defaults page.
**Features:**
- Tabbed interface: "General Settings" / "CSS Styles" / "Effects"
- Form-based editing (no raw JSON textarea)
- Type-specific form sections via shared components
- "Reset to Global" button to restore global default values
- Diff indicator when settings differ from global defaults
- All changes immediately visible in constructor when editing project defaults
**Note:** Prior to the ElementSettings refactoring, this page used a raw JSON textarea for editing `settings_json`. It now uses the same user-friendly form components as the global defaults page, providing a consistent editing experience across scopes.
### Page Elements
Page elements are stored inline in `tour_pages.ui_schema_json` and managed through the tour_pages API:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tour_pages/:id` | Get page with elements in ui_schema_json |
| PUT | `/api/tour_pages/:id` | Update page (includes ui_schema_json with elements) |
**Note:** There is no separate `/api/page_elements` endpoint. Elements are part of the tour_pages record.
## Frontend Implementation
### TypeScript Types
**File:** `frontend/src/types/constructor.ts`
```typescript
// Available element types in constructor (unified across all scopes)
type CanvasElementType =
| 'navigation_next'
| 'navigation_prev'
| 'spot'
| 'description'
| 'gallery'
| 'carousel'
| 'logo'
| 'video_player'
| 'audio_player'
| 'popup'
| 'info_panel'; // Info panel with images/embeds
// Navigation button direction
type NavigationButtonKind = 'forward' | 'back';
// Info panel section types
type InfoPanelSectionType = 'header' | 'title' | 'text' | 'spans' | 'cards' | 'images';
// Info panel section instance
interface InfoPanelSectionInstance {
id: string;
type: InfoPanelSectionType;
columns?: number;
gap?: string;
spans?: InfoPanelInfoSpan[];
images?: InfoPanelImage[];
text?: string;
title?: string;
headerImageUrl?: string;
headerText?: string;
}
// Info panel info span
interface InfoPanelInfoSpan {
id: string;
text: string;
iconUrl?: string;
}
// Info panel image/embed
interface InfoPanelImage {
id: string;
imageUrl?: string;
embedUrl?: string;
caption?: string;
itemType?: 'image' | '360';
iconUrl?: string;
}
// Gallery card item
interface GalleryCard {
id: string;
imageUrl: string;
title: string;
description: string;
}
// Carousel slide item
interface CarouselSlide {
id: string;
imageUrl: string;
caption: string;
}
// Gallery info span (brief note badge)
interface GalleryInfoSpan {
id: string;
text: string;
}
// Base element with positioning
interface BaseCanvasElement {
id: string;
type: CanvasElementType | string;
label?: string;
xPercent?: number;
yPercent?: number;
// Styling fields
width?: string;
height?: string;
// ... other style properties
appearDelaySec?: number;
appearDurationSec?: number | null;
}
// Full element with all content fields
interface CanvasElement extends BaseCanvasElement {
// Navigation fields
navLabel?: string;
navLabelFontFamily?: string; // Font key for label text
navType?: NavigationButtonKind;
navDisabled?: boolean;
targetPageSlug?: string; // Slug-based navigation (consistent across environments)
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
// Tooltip fields
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;
tooltipTextFontFamily?: string;
// Description fields
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
descriptionTextFontSize?: string;
descriptionTitleFontFamily?: string;
descriptionTextFontFamily?: string;
descriptionTitleColor?: string;
descriptionTextColor?: string;
// Note: Background color uses the shared backgroundColor property from CSS Styles
// Media fields
mediaUrl?: string;
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
// Gallery fields
galleryCards?: GalleryCard[];
galleryHeaderImageUrl?: string;
galleryTitle?: string;
galleryInfoSpans?: GalleryInfoSpan[];
galleryColumns?: number;
galleryTitleFontFamily?: string;
galleryTextFontFamily?: string; // Font for card titles, descriptions, and info spans
// Gallery header dimensions
galleryHeaderWidth?: string;
galleryHeaderHeight?: string;
galleryHeaderMinHeight?: string;
galleryHeaderMaxHeight?: string;
// Gallery card dimensions
galleryCardWidth?: string;
galleryCardHeight?: string;
galleryCardMinHeight?: string;
galleryCardAspectRatio?: string;
// Gallery carousel navigation
galleryCarouselPrevIconUrl?: string;
galleryCarouselNextIconUrl?: string;
galleryCarouselBackIconUrl?: string;
galleryCarouselBackLabel?: string;
galleryCarouselPrevX?: number;
galleryCarouselPrevY?: number;
galleryCarouselNextX?: number;
galleryCarouselNextY?: number;
galleryCarouselBackX?: number;
galleryCarouselBackY?: number;
galleryCarouselPrevWidth?: string;
galleryCarouselPrevHeight?: string;
galleryCarouselNextWidth?: string;
galleryCarouselNextHeight?: string;
galleryCarouselBackWidth?: string;
galleryCarouselBackHeight?: string;
// Gallery section text alignment (default: 'center')
galleryHeaderTextAlign?: 'left' | 'center' | 'right';
galleryTitleTextAlign?: 'left' | 'center' | 'right';
gallerySpanTextAlign?: 'left' | 'center' | 'right';
// Carousel fields
carouselSlides?: CarouselSlide[];
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
carouselCaptionFontFamily?: string;
carouselFullWidth?: boolean;
// Carousel button positions (percentage 0-100, for full-width mode)
carouselPrevX?: number;
carouselPrevY?: number;
carouselNextX?: number;
carouselNextY?: number;
// Carousel button dimensions (CSS values like '3vw', '5vh')
carouselPrevWidth?: string;
carouselPrevHeight?: string;
carouselNextWidth?: string;
carouselNextHeight?: string;
// Carousel slide transition override (inherits from page transitions if not set)
carouselSlideTransitionType?: 'fade' | 'none' | '';
carouselSlideTransitionDurationMs?: number | '';
carouselSlideTransitionEasing?: EasingFunction | '';
carouselSlideTransitionOverlayColor?: string;
// Gallery slide transition override (for fullscreen carousel overlay)
gallerySlideTransitionType?: 'fade' | 'none' | '';
gallerySlideTransitionDurationMs?: number | '';
gallerySlideTransitionEasing?: EasingFunction | '';
gallerySlideTransitionOverlayColor?: string;
// Popup fields
popupTitle?: string;
popupContent?: string;
// Info Panel fields
infoPanelTriggerLabel?: string;
infoPanelTriggerFontFamily?: string;
infoPanelDisabled?: boolean;
infoPanelOpenByDefault?: boolean;
panelTitle?: string;
panelText?: string;
infoPanelHeaderImageUrl?: string;
infoPanelHeaderText?: string;
// Panel position & styling
panelXPercent?: number;
panelYPercent?: number;
panelWidth?: string;
panelHeight?: string;
panelBackgroundColor?: string;
panelBorderRadius?: string;
panelPadding?: string;
panelBackdropBlur?: string;
panelOverlayColor?: string;
panelBorderWidth?: string;
panelBorderColor?: string;
panelBorderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
infoPanelSectionGap?: string;
// Section instances
infoPanelSections?: InfoPanelSectionInstance[];
// Header section styles
infoPanelHeaderBackgroundColor?: string;
infoPanelHeaderColor?: string;
infoPanelHeaderFontFamily?: string;
infoPanelHeaderFontSize?: string;
infoPanelHeaderFontWeight?: string;
infoPanelHeaderPadding?: string;
infoPanelHeaderBorderRadius?: string;
infoPanelHeaderTextAlign?: 'left' | 'center' | 'right';
infoPanelHeaderWidth?: string;
infoPanelHeaderHeight?: string;
infoPanelHeaderMinHeight?: string;
infoPanelHeaderMaxHeight?: string;
// Title section styles
panelTitleColor?: string;
panelTitleFontSize?: string;
panelTitleFontFamily?: string;
infoPanelTitleBackgroundColor?: string;
infoPanelTitlePadding?: string;
infoPanelTitleFontWeight?: string;
infoPanelTitleTextAlign?: 'left' | 'center' | 'right';
// Text section styles
panelTextColor?: string;
panelTextFontSize?: string;
panelTextFontFamily?: string;
// Span section styles
infoPanelSpanBackgroundColor?: string;
infoPanelSpanColor?: string;
infoPanelSpanFontFamily?: string;
infoPanelSpanFontSize?: string;
infoPanelSpanPadding?: string;
infoPanelSpanBorderRadius?: string;
infoPanelSpanGap?: string;
// Card section styles
infoPanelCardBackgroundColor?: string;
infoPanelCardBorderRadius?: string;
infoPanelCardAspectRatio?: string;
infoPanelCardMinHeight?: string;
infoPanelCardGap?: string;
infoPanelCardTitleBackgroundColor?: string;
infoPanelCardTitleColor?: string;
infoPanelCardTitleFontFamily?: string;
infoPanelCardTitleFontSize?: string;
infoPanelCardTitlePadding?: string;
// Media section
infoPanelImagesPreviewHeight?: string;
infoPanelImagesThumbnailSize?: string;
// Image Detail Panel
detailXPercent?: number;
detailYPercent?: number;
detailWidth?: string;
detailHeight?: string;
detailBackgroundColor?: string;
detailBorderRadius?: string;
detailPadding?: string;
detailCaptionFontFamily?: string;
detailBorderWidth?: string;
detailBorderColor?: string;
detailBorderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
}
// Element type default from database (renamed from UiElementDefault)
interface ElementTypeDefault {
id: string;
element_type?: string;
is_active?: boolean;
default_settings_json?: string | Record;
}
```
### Element Settings Components
**Directory:** `frontend/src/components/ElementSettings/`
The platform uses shared form components for editing element settings across all three scopes (global, project, instance). This ensures UI consistency and reduces code duplication.
#### Component Structure
```
frontend/src/components/ElementSettings/
├── index.ts # Barrel exports
├── types.ts # Shared TypeScript types
├── useElementSettingsForm.ts # Form state management hook
├── ElementSettingsTabs.tsx # Tab navigation (regular + compact)
├── CommonSettingsSection.tsx # Label, position, appearance timing
├── CommonSettingsSectionCompact.tsx # Label, position (compact for sidebar)
├── StyleSettingsSection.tsx # CSS properties (full width)
├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar)
├── EffectsSettingsSection.tsx # Visual effects (full width)
├── EffectsSettingsSectionCompact.tsx # Visual effects (compact for sidebar)
├── NavigationSettingsSection.tsx # Navigation element settings
├── NavigationSettingsSectionCompact.tsx # Navigation (compact)
├── DescriptionSettingsSection.tsx # Description element settings
├── DescriptionSettingsSectionCompact.tsx # Description (compact)
├── MediaSettingsSection.tsx # Video/audio player settings
├── MediaSettingsSectionCompact.tsx # Media (compact)
├── GallerySettingsSection.tsx # Gallery cards editor
├── GallerySettingsSectionCompact.tsx # Gallery (compact)
├── CarouselSettingsSection.tsx # Carousel slides editor
├── CarouselSettingsSectionCompact.tsx # Carousel (compact)
└── GalleryCarouselSettingsSectionCompact.tsx # Gallery carousel nav settings (compact)
```
#### useElementSettingsForm Hook
**File:** `frontend/src/components/ElementSettings/useElementSettingsForm.ts`
Manages ~60 form state fields for element settings:
```typescript
const form = useElementSettingsForm({ elementType: 'navigation_next' });
// Apply settings from JSON (e.g., from API response)
form.applySettings(item.settings_json);
// Build settings JSON for saving
const settings = form.buildSettingsJson();
// Type detection helpers
if (form.isNavigationType) { /* show navigation fields */ }
if (form.isInfoPanelType) { /* show info panel fields */ }
// Gallery/carousel operations
form.addGalleryCard();
form.removeGalleryCard(cardId);
form.updateGalleryCard(cardId, 'title', 'New Title');
```
#### Tab Navigation
Two variants for different contexts:
```typescript
// Full-size tabs for admin pages
import { ElementSettingsTabs } from '../../components/ElementSettings';
const SETTINGS_TABS = [
{ id: 'general', label: 'General Settings' },
{ id: 'css', label: 'CSS Styles' },
{ id: 'effects', label: 'Effects' },
];
// Compact tabs for constructor sidebar
import { ElementSettingsTabsCompact } from '../../components/ElementSettings';
```
#### Context-Aware Rendering
Components accept a `context` prop to render differently based on usage:
| Context | Asset Selection | Input Style |
|---------|-----------------|-------------|
| `global` | Text input (URLs) | Full-width inputs |
| `project` | Text input (URLs) | Full-width inputs |
| `constructor` | Asset dropdown (from project assets) | Compact inputs |
### Admin Pages
#### Element Type Defaults List Page
**File:** `frontend/src/pages/element-type-defaults.tsx`
**Permission:** `READ_PAGE_ELEMENTS`
**Features:**
- Lists all 12 predefined element types
- Shows name, element_type (humanized), sort_order, is_active status
- Links to detail page for editing
```typescript
// Fetch all element types ordered by sort_order
const response = await axios.get(
'/element-type-defaults?limit=1000&page=1&sort=asc&field=sort_order'
);
```
#### Element Type Default Details Page
**File:** `frontend/src/pages/element-type-defaults/[id].tsx`
**Permission:** `UPDATE_PAGE_ELEMENTS`
Uses shared `ElementSettings` components with `useElementSettingsForm` hook.
**Features:**
- Tabbed interface: "General Settings" / "CSS Styles" / "Effects"
- Edit default settings for any element type
- Type-specific form sections (via shared components):
- **CommonSettingsSection:** Label, position (X%, Y%), appearance timing
- **StyleSettingsSection:** Dimensions, spacing, typography, effects
- **NavigationSettingsSection:** Icon, label, nav type, transition settings
- **DescriptionSettingsSection:** Title, text, fonts, colors
- **GallerySettingsSection:** Card array editor (add/remove cards)
- **CarouselSettingsSection:** Slide array editor, prev/next icons
- **MediaSettingsSection:** URL, autoplay, loop, muted flags
- **InfoPanelSettingsSection:** Section ordering, spans/images data, panel/detail styling
### Constructor Integration
**File:** `frontend/src/pages/constructor.tsx`
#### Element Editor Tabs
The constructor's element editing sidebar now includes tabs for organizing settings:
```
┌───────────────────────────────────────────┐
│ [General] [CSS Styles] [Effects] │ ← Tab bar (ElementSettingsTabsCompact)
├───────────────────────────────────────────┤
│ Content based on selected tab │
│ │
│ General: label, icon, target page, │
│ type-specific content │
│ │
│ CSS: width, height, border, │
│ margin, padding, opacity, etc. │
│ │
│ Effects: visual effects, animations, │
│ transitions, transforms │
└───────────────────────────────────────────┘
```
**Implementation:**
```typescript
import {
ElementSettingsTabsCompact,
StyleSettingsSectionCompact,
EffectsSettingsSectionCompact,
extractNumericValue,
} from '../../components/ElementSettings';
const [elementEditorTab, setElementEditorTab] = useState<'general' | 'css' | 'effects'>('general');
// Tab navigation
// CSS Styles tab content
{elementEditorTab === 'css' && (
{
// Handle unit conversion (vw, vh, px)
updateSelectedElement({ [prop]: formattedValue });
}}
/>
)}
```
**CSS Unit Conventions:**
| Property | Unit | Example | Auto-Normalization |
|----------|------|---------|-------------------|
| width, minWidth, maxWidth | vw | `24vw` | `"24"` → `"24vw"` |
| height, minHeight, maxHeight | vh | `8vh` | `"8"` → `"8vh"` |
| border | (complex) | `2px solid currentColor` | No (preserved as-is) |
| borderRadius | px | `8px` | `"8"` → `"8px"` |
| Gallery fontSize/padding/gap | rem | `0.875rem` | `"0.875"` → `"0.875rem"` |
**Note:** Unit normalization happens both at form save time (via `toUnitValue` in `types.ts`) and at render time (via `buildElementStyle` and gallery section style builders). Values with existing units, CSS functions, or complex values are preserved as-is.
#### Loading Project Defaults
The constructor fetches project-specific element defaults on load:
```typescript
// Fetch project element defaults (not global!)
const uiElementsResponse = await axios.get(
`/project-element-defaults?projectId=${projectId}&limit=200&page=1&sort=asc&field=sort_order`,
);
// Normalize and build lookup map using shared utilities from types/constructor.ts
const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows)
? uiElementsResponse.data.rows
: [];
const normalizedDefaults = uiElementRows
.map((row) => normalizeElementDefault(row))
.filter((d): d is NormalizedElementDefault => d !== null);
const defaultsByType = buildElementDefaultsMap(normalizedDefaults);
setUiElementDefaultsByType(defaultsByType);
```
#### Shared Normalization Utilities
**File:** `frontend/src/types/constructor.ts`
```typescript
// Handles both settings_json (project) and default_settings_json (global)
function normalizeElementDefault(row: Record): NormalizedElementDefault | null {
const elementType = String(row.element_type || '').trim();
if (!elementType) return null;
const rawSettings = row.settings_json ?? row.default_settings_json;
let settings: Partial = {};
if (typeof rawSettings === 'string') {
try { settings = JSON.parse(rawSettings); } catch { settings = {}; }
} else if (rawSettings && typeof rawSettings === 'object') {
settings = rawSettings as Partial;
}
return { id: String(row.id || ''), element_type: elementType, settings };
}
// Build element type to defaults lookup map
function buildElementDefaultsMap(defaults: NormalizedElementDefault[]) {
const map: Partial>> = {};
for (const def of defaults) {
if (isCanvasElementType(def.element_type)) {
map[def.element_type] = def.settings;
}
}
return map;
}
```
#### Creating New Elements
The constructor creates elements with inline defaults, then merges with project defaults:
```typescript
// Create element with inline/hardcoded defaults
const createDefaultElement = (
type: CanvasElementType,
index: number,
): CanvasElement => {
const base: CanvasElement = {
id: createLocalId(),
type,
label: labelByType[type],
xPercent: clamp(12 + index * 4, 5, 80), // Staggered positioning
yPercent: clamp(16 + index * 6, 8, 85),
appearDelaySec: 0,
appearDurationSec: null,
};
// Type-specific defaults added inline
if (type === 'gallery') {
return { ...base, galleryCards: [{ id, imageUrl: '', title: 'Card 1', description: '' }] };
}
// ... other types
return base;
};
// Add element with project defaults applied
const addElement = (type: CanvasElementType) => {
const baseElement = createDefaultElement(type, elements.length);
const nextElement = mergeElementWithDefaults(
baseElement,
uiElementDefaultsByType[type], // Project defaults (or undefined)
);
setElements((prev) => [...prev, nextElement]);
};
```
#### Loading Existing Elements
When loading elements from `ui_schema_json`, existing values take precedence over defaults:
```typescript
// mergeElementWithDefaults with preferElementValues: true
// → Element's stored values are preserved, defaults only fill missing fields
return mergeElementWithDefaults(
normalizedElement,
uiElementDefaultsByType[elementType],
{ preferElementValues: true }, // Existing element values take precedence
);
```
#### Merge Logic
```typescript
// Merge instance values with defaults (from project scope)
const mergeElementWithDefaults = (
element: CanvasElement,
defaults?: Partial,
options?: { preferElementValues?: boolean },
): CanvasElement => {
if (!defaults) return element; // Graceful handling if no project defaults
const preferElementValues = Boolean(options?.preferElementValues);
const base = preferElementValues ? defaults : element;
const override = preferElementValues ? element : defaults;
return {
...base,
...override,
id: element.id, // Always preserve element id
type: element.type, // Always preserve element type
};
};
```
## Scope Comparison
| Aspect | Global (element_type_defaults) | Project (project_element_defaults) | Page Elements (ui_schema_json) |
|--------|-------------------------------|-----------------------------------|-------------------------------|
| **Purpose** | Platform-wide defaults | Project-specific overrides | Page-specific instances |
| **Count** | 11 auto-seeded types | 11 per project (auto-snapshotted) | Unlimited per page |
| **Storage** | `settings_json` (TEXT) | `settings_json` (TEXT) | `tour_pages.ui_schema_json` |
| **Field Alias** | `default_settings_json` (model alias) | `settings_json` (direct) | N/A (inline JSON) |
| **Element Type** | TEXT (globally unique) | TEXT (unique per project) | TEXT (flexible) |
| **Parent** | None | projects (CASCADE on delete) | tour_pages (inline storage) |
| **Position Data** | In settings_json | In settings_json | Directly in element object |
| **Creation** | Auto-seeded on first API access | Auto-snapshotted on project create | Manual via constructor |
| **Management** | Admin UI (/element-type-defaults) | Project settings (/project-element-defaults) | Constructor drag-drop |
| **API Query** | `/element-type-defaults` | `/project-element-defaults?projectId=xxx` | `/tour_pages/:id` |
| **Inheritance** | Base layer (template) | Copies from global, can diverge | Merges with project defaults |
### Project Element Defaults API Filtering
The `project_element_defaults` API implements custom filtering by project:
```bash
# Filter by project UUID
GET /api/project-element-defaults?projectId=
# Filter by project name (case-insensitive)
GET /api/project-element-defaults?project=
# Both params supported, can use pipe for multiple values
GET /api/project-element-defaults?projectId=uuid1|uuid2
```
## Settings Inheritance Flow
### New Element Creation
```
┌─────────────────────────────────────────────────────────────────┐
│ User clicks "Add Navigation" in constructor │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. createDefaultElement('navigation_next', index) │
│ → Creates base element with hardcoded defaults: │
│ - id: generated UUID │
│ - type: 'navigation_next' │
│ - label: 'Navigation: Forward' │
│ - xPercent/yPercent: staggered position │
│ - Type-specific fields (navLabel, navType, etc.) │
│ │
│ 2. mergeElementWithDefaults(base, uiElementDefaultsByType[type])│
│ → Looks up project defaults from pre-loaded map │
│ → If found: merges project settings over hardcoded base │
│ → If not found: returns hardcoded base unchanged │
│ │
│ 3. Element added to state with merged values │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Loading Existing Elements
```
┌─────────────────────────────────────────────────────────────────┐
│ Page loads with existing elements in ui_schema_json │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Parse ui_schema_json.elements[] │
│ → Normalize each element's field values │
│ │
│ 2. mergeElementWithDefaults(element, defaults, │
│ { preferElementValues: true }) │
│ → Element's stored values TAKE PRECEDENCE │
│ → Project defaults only fill MISSING fields │
│ │
│ 3. Display elements with complete merged settings │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Data Flow Summary
```
┌────────────────────┐
│ Global Defaults │ (element_type_defaults)
│ - 11 auto-seeded │
│ - Platform-wide │
└─────────┬──────────┘
│
│ snapshotGlobalDefaults() on project creation
▼
┌────────────────────┐
│ Project Defaults │ (project_element_defaults)
│ - Copied from │
│ global on create │
│ - Customizable │
└─────────┬──────────┘
│
│ Constructor loads via /project-element-defaults?projectId=xxx
│ buildElementDefaultsMap() creates lookup table
▼
┌────────────────────┐
│ Constructor State │ (uiElementDefaultsByType)
│ - In-memory map │
│ - Used for merge │
└─────────┬──────────┘
│
│ mergeElementWithDefaults() on add/load
▼
┌────────────────────┐
│ Page Elements │ (tour_pages.ui_schema_json)
│ - Stored with full │
│ merged settings │
│ - No refs to │
│ defaults │
└────────────────────┘
```
## Asset Preloading
**File:** `frontend/src/config/preload.config.ts`
UI element assets are automatically extracted for preloading:
```typescript
assetFields: {
all: [
'iconUrl', // Navigation, tooltips, info panel triggers
'imageUrl', // Gallery, carousel, info panel images
'mediaUrl', // Video/audio players
'videoUrl',
'audioUrl',
'transitionVideoUrl',
'backgroundImageUrl',
'reverseVideoUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'infoPanelHeaderImageUrl', // Info panel header image
'src', // Generic source URLs
'url', // Generic URLs
'poster', // Video poster images
'thumbnail' // Thumbnail images
],
images: [ // Image-only fields for pre-decode
'iconUrl',
'imageUrl',
'backgroundImageUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'infoPanelHeaderImageUrl',
'src'
],
nested: ['galleryCards', 'carouselSlides', 'infoPanelSections'],
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl', 'headerImageUrl', 'embedUrl']
}
```
**Preload Priority by Asset Type:**
| Asset Type | Priority Bonus | Description |
|------------|----------------|-------------|
| Transition video | +150 | Highest - needed immediately on navigation click |
| Image | +100 | Backgrounds and icons |
| Audio | +50 | Background audio |
| Video | +30 | Background videos can stream progressively |
**Note:** `maxNeighborDepth` defaults to 1 (immediate neighbors only). Current page assets get base priority 1000, neighbors get 500.
The preload orchestrator:
1. Extracts all asset URLs from element configurations using `extractPageLinksAndElements()`
2. Fetches S3 presigned URLs via `POST /api/file/presign` (max 50 per batch, 1-hour expiry)
3. Downloads assets directly from S3 and stores in Cache API (dual key: download URL + storage key)
4. Creates blob URLs and stores in `readyBlobUrlsRef` (dual key: download URL + storage key) for O(1) instant lookup
5. Pre-decodes images before page transitions using the blob URLs
6. Manages concurrent downloads (max 3)
**Storage Key Mapping:** Assets are cached by canonical storage key (e.g., `assets/project/icon.png`) in addition to download URLs. This ensures cache hits even when presigned URL signatures change.
**Blob URL Rendering:** When displaying preloaded assets, use native `` tags for blob URLs instead of Next.js `` to prevent re-fetching on component re-renders (see `assets-preloading.md` for details).
## File References
### Backend
| File | Purpose |
|------|---------|
| `backend/src/db/models/element_type_defaults.js` | Global scope model |
| `backend/src/db/models/project_element_defaults.js` | Project scope model |
| `backend/src/db/models/tour_pages.js` | Tour pages model (elements in ui_schema_json) |
| `backend/src/db/api/element_type_defaults.ts` | Global scope DB API + auto-seed |
| `backend/src/db/api/project_element_defaults.ts` | Project scope DB API + snapshot |
| `backend/src/routes/element_type_defaults.ts` | Global scope routes |
| `backend/src/routes/project_element_defaults.ts` | Project scope routes |
| `backend/src/services/element_type_defaults.ts` | Global scope service |
| `backend/src/services/project_element_defaults.ts` | Project scope service |
### Frontend
| File | Purpose |
|------|---------|
| `frontend/src/types/constructor.ts` | TypeScript types |
| `frontend/src/pages/element-type-defaults.tsx` | Global defaults admin list |
| `frontend/src/pages/element-type-defaults/[id].tsx` | Global defaults admin details |
| `frontend/src/pages/project-element-defaults/[id].tsx` | Project defaults details |
| `frontend/src/pages/constructor.tsx` | Element editor (manages elements in ui_schema_json) |
| `frontend/src/components/UiElements/defaults.ts` | Frontend fallback defaults |
| `frontend/src/config/preload.config.ts` | Asset preloading config |
#### Element Rendering Components
| File | Purpose |
|------|---------|
| `frontend/src/components/UiElements/UiElementRenderer.tsx` | Unified rendering entry point |
| `frontend/src/components/UiElements/shared/useElementWrapperStyle.ts` | Shared wrapper styling hook |
| `frontend/src/components/UiElements/elements/NavigationElement.tsx` | Navigation button (next/prev) |
| `frontend/src/components/UiElements/elements/GalleryElement.tsx` | Image gallery grid |
| `frontend/src/components/UiElements/elements/DescriptionElement.tsx` | Description text block |
| `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Image carousel |
| `frontend/src/components/UiElements/elements/LogoElement.tsx` | Logo display |
| `frontend/src/components/UiElements/elements/SpotElement.tsx` | Hotspot/clickable area |
| `frontend/src/components/UiElements/elements/VideoPlayerElement.tsx` | Embedded video player |
| `frontend/src/components/UiElements/elements/AudioPlayerElement.tsx` | Embedded audio player |
| `frontend/src/components/UiElements/elements/PopupElement.tsx` | Modal/popup dialog |
| `frontend/src/components/UiElements/elements/InfoPanelElement.tsx` | Info panel trigger button |
| `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay with sections; supports `renderBackdrop` and `onBackdropClose` so multiple open panels share one backdrop |
| `frontend/src/components/UiElements/ImageDetailPanel.tsx` | Image detail panel for cards/360° |
| `frontend/src/components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection, drag) |
| `frontend/src/components/RuntimeElement.tsx` | Runtime wrapper (position, effects) |
#### Element Settings Components
| File | Purpose |
|------|---------|
| `frontend/src/components/ElementSettings/index.ts` | Barrel exports |
| `frontend/src/components/ElementSettings/types.ts` | Shared TypeScript types |
| `frontend/src/components/ElementSettings/useElementSettingsForm.ts` | Form state management hook |
| `frontend/src/components/ElementSettings/ElementSettingsTabs.tsx` | Tab navigation component |
| `frontend/src/components/ElementSettings/CommonSettingsSection.tsx` | Common settings (label, position, timing) |
| `frontend/src/components/ElementSettings/CommonSettingsSectionCompact.tsx` | Common settings (compact) |
| `frontend/src/components/ElementSettings/StyleSettingsSection.tsx` | CSS styling (full-width version) |
| `frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) |
| `frontend/src/components/ElementSettings/EffectsSettingsSection.tsx` | Visual effects (full-width) |
| `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Visual effects (compact) |
| `frontend/src/components/ElementSettings/NavigationSettingsSection.tsx` | Navigation element fields |
| `frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx` | Navigation (compact) |
| `frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx` | Description element fields |
| `frontend/src/components/ElementSettings/DescriptionSettingsSectionCompact.tsx` | Description (compact) |
| `frontend/src/components/ElementSettings/MediaSettingsSection.tsx` | Video/audio player fields |
| `frontend/src/components/ElementSettings/MediaSettingsSectionCompact.tsx` | Media (compact) |
| `frontend/src/components/ElementSettings/GallerySettingsSection.tsx` | Gallery cards editor |
| `frontend/src/components/ElementSettings/GallerySettingsSectionCompact.tsx` | Gallery (compact) |
| `frontend/src/components/ElementSettings/CarouselSettingsSection.tsx` | Carousel slides editor |
| `frontend/src/components/ElementSettings/CarouselSettingsSectionCompact.tsx` | Carousel (compact) |
| `frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx` | Gallery carousel nav settings (compact) |
| `frontend/src/components/ElementSettings/GallerySectionStyleInputs.tsx` | Gallery section styling (header, title, spans, cards) with text alignment support |
| `frontend/src/components/ElementSettings/InfoPanelSettingsSection.tsx` | Info panel sections editor (full-width) |
| `frontend/src/components/ElementSettings/InfoPanelSettingsSectionCompact.tsx` | Info panel sections editor (compact for constructor sidebar) |
| `frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx` | Info panel section styling (panel wrapper, image detail panel) |
### Migrations
| File | Purpose |
|------|---------|
| `20260326000001-rename-ui-elements-to-element-type-defaults.js` | Rename table |
| `20260326000002-convert-element-type-enum-to-text.js` | ENUM to TEXT |
| `20260326000003-create-project-element-defaults.js` | Create project scope table |
| `20260326000004-backfill-project-element-defaults.js` | Backfill existing projects |
## Known Considerations
### 1. Virtual is_active Field
**Issue:** `element_type_defaults.is_active` always returns `true` (virtual field). It's not actually stored or used.
**Impact:** The "Active" checkbox in the admin UI has no real effect on element availability.
### 2. Auto-Snapshot on Project Creation
**Behavior:** When a project is created, all global defaults are automatically snapshotted to `project_element_defaults`.
**Impact:** Projects start with a frozen copy of global defaults at creation time. Changes to global defaults don't affect existing projects unless manually reset via `/api/project-element-defaults/:id/reset`.
### 3. Existing Projects Without Snapshots
**Issue:** Projects created before the snapshot mechanism was added won't have `project_element_defaults` records.
**Impact:** Constructor will load an empty `uiElementDefaultsByType` map, falling back to hardcoded defaults.
**Mitigation:**
- Migration `20260326000004-backfill-project-element-defaults.js` backfills existing projects
- Migration `20260326171017-add-missing-element-type-defaults.js` adds spot, logo, popup types
- Constructor handles empty defaults gracefully
### 4. Field Name Aliasing
**Issue:** Different tables use different field names for the same data:
- `element_type_defaults`: `default_settings_json` (alias for `settings_json` in DB)
- `project_element_defaults`: `settings_json` (direct)
**Mitigation:** The `normalizeElementDefault()` utility in `types/constructor.ts` handles both:
```typescript
const rawSettings = row.settings_json ?? row.default_settings_json;
```
### 5. TEXT vs ENUM for element_type
**Design Decision:** `element_type` uses TEXT everywhere instead of ENUM.
**Benefits:**
- New element types can be added without database migrations
- Consistent across all three tiers
- Application-level validation ensures type safety
## Font Handling
### Font Key System
Font family properties use font **keys** rather than CSS `font-family` values. This allows distinguishing between font variants that share the same family name but differ in other properties (e.g., `fontStretch` for condensed variants).
**File:** `frontend/src/lib/fonts.ts`
```typescript
// Font keys are unique identifiers
const FONT_OPTIONS = [
{ key: 'instrument-sans', fontFamily: 'Instrument Sans', label: 'Instrument Sans' },
{ key: 'instrument-sans-condensed', fontFamily: 'Instrument Sans', fontStretch: 'condensed', label: 'Instrument Sans Condensed' },
// ...
];
// Resolve font key to full CSS style
function getFontByKey(key: string): FontOption | undefined;
function getFontStyle(font: FontOption): CSSProperties;
```
### Element Font Properties
| Element Type | Font Properties |
|--------------|-----------------|
| Navigation | `navLabelFontFamily` |
| Description | `descriptionTitleFontFamily`, `descriptionTextFontFamily` |
| Gallery | `galleryTitleFontFamily`, `galleryTextFontFamily` |
| Carousel | `carouselCaptionFontFamily` |
| Info Panel | `infoPanelTriggerFontFamily`, `infoPanelHeaderFontFamily`, `panelTitleFontFamily`, `panelTextFontFamily`, `infoPanelSpanFontFamily`, `infoPanelCardTitleFontFamily`, `detailCaptionFontFamily` |
### Font Resolution Pattern
Element rendering components resolve font keys to CSS styles using `useMemo`:
```typescript
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
const labelFontStyle = useMemo(() => {
const fontKey = element.navLabelFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.navLabelFontFamily]);
// Applied to text elements
{element.navLabel}
```
### Settings Component Pattern
Font selectors use `font.key` as the option value:
```typescript
import { FONT_OPTIONS } from '../../lib/fonts';
```
## Security
### Permissions
Element defaults use `element_type_defaults` and `project_element_defaults` permissions. Page elements are managed through `tour_pages` permissions.
| Permission | Operations |
|------------|------------|
| READ_ELEMENT_TYPE_DEFAULTS | List, view global defaults |
| UPDATE_ELEMENT_TYPE_DEFAULTS | Edit global defaults (admin) |
| READ_PROJECT_ELEMENT_DEFAULTS | List, view project defaults |
| UPDATE_PROJECT_ELEMENT_DEFAULTS | Edit project defaults |
| READ_TOUR_PAGES | View pages (includes elements in ui_schema_json) |
| UPDATE_TOUR_PAGES | Edit pages (includes elements in ui_schema_json) |
### Validation
**Global scope (element_type_defaults):**
- `element_type`: Required, unique, 1-100 chars
- `name`: Required, 1-255 chars
**Project scope (project_element_defaults):**
- `projectId`: Required FK (validated by Sequelize)
- `element_type`: Required, 1-100 chars
- Unique constraint: `(projectId, element_type)`
**Page elements (in ui_schema_json):**
- Validated at application level in constructor
- `type`: Required, must be a valid element type
- Position values (xPercent, yPercent): 0-100 range