110 KiB
UI Elements
Complete documentation for the Tour Builder Platform's UI Elements system including configuration, settings, and the three-tier scope hierarchy.
Overview
The platform implements a three-tier UI Elements defaults hierarchy:
- Global Scope (element_type_defaults) - Platform-wide default configurations for element types
- Project Scope (project_element_defaults) - Project-specific settings snapshots/overrides
- Instance Scope (tour_pages.ui_schema_json) - Page-specific element instances with custom settings
Note: Page-specific element instances are stored directly in tour_pages.ui_schema_json, not in a separate table.
Write contract: tour_pages create/update requests validate ui_schema_json
as a top-level JSON object. Valid object payloads must either contain
elements as an array or omit it; omitted elements is normalized to
elements: [] before saving. JSON strings are accepted for legacy callers,
parsed by the backend service, and rejected if the parsed value is not an
object or if elements is not an array. The project does not currently use a
schema/version field, derived metadata tables, or normalized page-element
tables; asset usage and preload discovery continue to use existing extraction
helpers.
┌─────────────────────────────────────────────────────────────────────────┐
│ UI Elements Architecture │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Global Scope (element_type_defaults) │ │
│ │ System-wide default configurations │ │
│ │ │ │
│ │ 12 predefined element types with default settings: │ │
│ │ • navigation_next • navigation_prev • spot • description │ │
│ │ • gallery • carousel • logo • video_player │ │
│ │ • audio_player • popup • info_panel │ │
│ └───────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ (auto-snapshot on project creation) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Project Scope (project_element_defaults) │ │
│ │ Project-specific overrides │ │
│ │ │ │
│ │ • Auto-snapshotted from global defaults on project creation │ │
│ │ • Can be customized per project │ │
│ │ • "Reset to Global" restores original values │ │
│ │ • Tracks source_element_id for "Check for Updates" │ │
│ └───────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ (applies defaults to new elements) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Page Elements (tour_pages.ui_schema_json) │ │
│ │ Page-specific element instances │ │
│ │ │ │
│ │ • Stored inline in tour_pages.ui_schema_json.elements[] │ │
│ │ • Unlimited instances per page │ │
│ │ • Custom position, styling, content │ │
│ │ • Navigation uses targetPageSlug (not IDs) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Constructor │ │ Runtime │ │
│ │ (Edit mode) │ │ (Playback) │ │
│ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Global Scope (element_type_defaults)
Database Model
File: backend/src/db/models/element_type_defaults.js
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| id | UUID | Yes | UUIDv4 | Primary key |
| element_type | TEXT | Yes | - | Unique identifier (e.g., 'navigation_next') |
| name | TEXT | Yes | - | Human-readable name (1-255 chars) |
| sort_order | INTEGER | Yes | 0 | Display order in admin UI |
| is_active | VIRTUAL | - | true | Always returns true (convenience field) |
| default_settings_json | TEXT | No | null | JSON-serialized default configuration |
| importHash | VARCHAR(255) | No | null | Unique hash for imports |
| createdAt | TIMESTAMP | Yes | Auto | Creation timestamp |
| updatedAt | TIMESTAMP | Yes | Auto | Update timestamp |
| deletedAt | TIMESTAMP | No | null | Soft delete timestamp |
Note: The default_settings_json field is stored as settings_json in the database (field alias).
Indexes:
element_type- Unique constraint (via column definition, creates implicit unique index)element_type- Additional non-unique index (explicitly defined)sort_order- For ordered queriesdeletedAt- Soft delete queries
Relationships:
element_type_defaults
├── hasMany project_element_defaults (as snapshots) ─── SET NULL on delete
├── belongsTo users (as createdBy) ─── Audit trail
└── belongsTo users (as updatedBy) ─── Audit trail
Predefined Element Types
File: backend/src/db/api/element_type_defaults.ts
The system auto-seeds 12 default element types on first database access via DEFAULT_ROWS:
| Element Type | Name | Sort Order | Purpose |
|---|---|---|---|
| navigation_next | Navigation Forward Button | 1 | Forward navigation |
| navigation_prev | Navigation Back Button | 2 | Back navigation |
| tooltip | Tooltip | 3 | Hover information |
| description | Description | 4 | Text content block |
| gallery | Gallery | 5 | Image gallery cards |
| carousel | Carousel | 6 | Image slideshow |
| video_player | Video Player | 7 | Embedded video |
| audio_player | Audio Player | 8 | Embedded audio |
| spot | Hotspot | 9 | Interactive clickable area |
| logo | Logo | 10 | Brand logo display |
| popup | Popup | 11 | Modal dialog |
| info_panel | Info Panel | 12 | Interactive panel with images/embeds |
Default Settings by Type
Navigation (navigation_next, navigation_prev)
{
label: 'Navigation: Forward', // or 'Navigation: Back'
navLabel: 'Forward', // or 'Back'
navLabelFontFamily: '', // Font key for label text (e.g., 'instrument-sans-condensed')
navType: 'forward', // 'forward' | 'back'
navDisabled: false,
transitionReverseMode: 'auto_reverse', // 'auto_reverse' | 'separate_video'
transitionDurationSec: 0.7,
appearDelaySec: 0,
appearDurationSec: null
}
Tooltip (Deprecated)
Note: The Tooltip element type has been deprecated and removed from the frontend constructor. The backend defaults remain for backward compatibility with existing projects.
{
label: 'Tooltip',
tooltipTitle: 'Tooltip title',
tooltipText: 'Tooltip text',
appearDelaySec: 0,
appearDurationSec: null
}
Description
{
label: 'Description',
descriptionTitle: 'TITLE',
descriptionText: '',
descriptionTitleFontSize: '48px',
descriptionTextFontSize: '36px',
descriptionTitleFontFamily: 'inherit',
descriptionTextFontFamily: 'inherit',
descriptionTitleColor: '#000000',
descriptionTextColor: '#4B5563',
appearDelaySec: 0,
appearDurationSec: null
}
Note: Background color is controlled via the CSS Styles tab (backgroundColor property) rather than a separate description-specific field.
Gallery
{
label: 'Gallery',
galleryCards: [
{ imageUrl: '', title: 'Card 1', description: '' }
],
appearDelaySec: 0,
appearDurationSec: null
}
Carousel
{
label: 'Carousel',
carouselSlides: [
{ imageUrl: '', caption: 'Slide 1' }
],
carouselPrevIconUrl: '',
carouselNextIconUrl: '',
carouselCaptionFontFamily: '',
carouselFullWidth: false,
// Button positions (percentage 0-100, for full-width mode)
carouselPrevX: 5,
carouselPrevY: 50,
carouselNextX: 95,
carouselNextY: 50,
// Button dimensions (CSS values, for navigation-style rendering)
carouselPrevWidth: '',
carouselPrevHeight: '',
carouselNextWidth: '',
carouselNextHeight: '',
appearDelaySec: 0,
appearDurationSec: null
}
Video Player
{
label: 'Video Player',
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: true,
appearDelaySec: 0,
appearDurationSec: null
}
Audio Player
{
label: 'Audio Player',
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: false, // Note: Audio is NOT muted by default
appearDelaySec: 0,
appearDurationSec: null
}
Spot (Hotspot)
{
label: 'Hotspot',
iconUrl: '',
appearDelaySec: 0,
appearDurationSec: null
}
Logo
{
label: 'Logo',
iconUrl: '',
backgroundImageUrl: '',
appearDelaySec: 0,
appearDurationSec: null
}
Popup
{
label: 'Popup',
iconUrl: '',
popupTitle: '',
popupContent: '',
appearDelaySec: 0,
appearDurationSec: null
}
Info Panel
{
label: 'Info Panel',
// Trigger position (width/height intentionally not set - trigger sizes based on content)
// Users can explicitly set width/height if fixed dimensions are needed
xPercent: 5,
yPercent: 90,
infoPanelTriggerFontFamily: '',
infoPanelDisabled: false,
infoPanelOpenByDefault: false,
// Hover reveal (disabled by default)
hoverReveal: false,
hoverRevealInitialOpacity: '1',
hoverRevealTargetOpacity: '1',
hoverRevealDuration: '0.3s',
// Header section
infoPanelHeaderImageUrl: '',
infoPanelHeaderText: '',
infoPanelHeaderBackgroundColor: '',
infoPanelHeaderColor: '#ffffff',
infoPanelHeaderFontFamily: '',
infoPanelHeaderFontSize: '24',
infoPanelHeaderFontWeight: '700',
infoPanelHeaderPadding: '8',
infoPanelHeaderBorderRadius: '8',
infoPanelHeaderTextAlign: 'center',
infoPanelHeaderMinHeight: '',
// Panel content
panelTitle: 'Information',
panelText: '',
// Layout
infoPanelSectionGap: '12',
// Panel position & wrapper styling
panelXPercent: 30,
panelYPercent: 50,
panelWidth: '400',
panelHeight: 'auto',
panelBackgroundColor: 'rgba(0, 0, 0, 0.85)',
panelBorderRadius: '12',
panelPadding: '20',
panelBackdropBlur: '10px',
panelOverlayColor: 'rgba(0, 0, 0, 0.3)',
panelBorderWidth: '0',
panelBorderColor: '#ffffff',
panelBorderStyle: 'solid',
// Title section styles
panelTitleColor: '#ffffff',
panelTitleFontSize: '18',
panelTitleFontFamily: '',
infoPanelTitleBackgroundColor: '',
infoPanelTitlePadding: '4 8',
infoPanelTitleFontWeight: '600',
infoPanelTitleTextAlign: 'left',
// Text section styles
panelTextColor: '#cccccc',
panelTextFontSize: '14',
panelTextFontFamily: '',
// Span section styles
infoPanelSpanBackgroundColor: 'rgba(255, 255, 255, 0.1)',
infoPanelSpanColor: '#ffffff',
infoPanelSpanFontFamily: '',
infoPanelSpanFontSize: '12',
infoPanelSpanPadding: '4 8',
infoPanelSpanBorderRadius: '6',
infoPanelSpanGap: '8',
// Card section styles
infoPanelCardBackgroundColor: 'rgba(0, 0, 0, 0.3)',
infoPanelCardBorderRadius: '8',
infoPanelCardAspectRatio: '16/9',
infoPanelCardMinHeight: '',
infoPanelCardGap: '8',
// Card title overlay styles
infoPanelCardTitleBackgroundColor: 'rgba(0, 0, 0, 0.6)',
infoPanelCardTitleColor: '#ffffff',
infoPanelCardTitleFontFamily: '',
infoPanelCardTitleFontSize: '12',
infoPanelCardTitlePadding: '4 8',
// Detail panel
detailXPercent: 70,
detailYPercent: 50,
detailWidth: '500',
detailHeight: '400',
detailBackgroundColor: 'rgba(0, 0, 0, 0.9)',
detailBorderRadius: '12',
detailPadding: '12',
detailCaptionFontFamily: '',
detailBorderWidth: '0',
detailBorderColor: '#ffffff',
detailBorderStyle: 'solid',
// Section instances with per-section data/settings
infoPanelSections: [
{ id: 'default-header', type: 'header' },
{ id: 'default-title', type: 'title' },
{ id: 'default-text', type: 'text' },
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
{ id: 'default-images', type: 'images', images: [] },
],
// Media section settings
infoPanelImagesPreviewHeight: '300',
infoPanelImagesThumbnailSize: '80',
appearDelaySec: 0,
appearDurationSec: null
}
Auto-Initialization
File: backend/src/db/api/element_type_defaults.ts
The ensureInitialized() method guarantees defaults exist:
static async ensureInitialized() {
// Singleton pattern with promise caching
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
let count = await this.MODEL.count();
// Handle table not existing (code 42P01)
// Sync table if needed
if (count > 0) return; // Already initialized
// Seed DEFAULT_ROWS
await this.MODEL.bulkCreate(
this.DEFAULT_ROWS.map(item => ({
...this.getFieldMapping(item),
createdAt: new Date(),
updatedAt: new Date()
}))
);
})();
}
await this.initializationPromise;
}
Every API method calls ensureInitialized() first, making the seeding idempotent.
Project Scope (project_element_defaults)
Database Model
File: backend/src/db/models/project_element_defaults.js
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| id | UUID | Yes | UUIDv4 | Primary key |
| projectId | UUID (FK) | Yes | - | Reference to projects |
| element_type | TEXT | Yes | - | Element type identifier |
| name | TEXT | No | null | Custom display name |
| sort_order | INTEGER | Yes | 0 | Display order |
| settings_json | TEXT | No | null | JSON-serialized settings |
| source_element_id | UUID (FK) | No | null | Reference to global default (for tracking) |
| snapshot_version | INTEGER | Yes | 1 | Version for update tracking |
| importHash | VARCHAR(255) | No | null | Unique import hash |
| createdAt | TIMESTAMP | Yes | Auto | Creation timestamp |
| updatedAt | TIMESTAMP | Yes | Auto | Update timestamp |
| deletedAt | TIMESTAMP | No | null | Soft delete timestamp |
Unique Constraint: (projectId, element_type) - One override per type per project
Indexes:
projectId- FK lookupprojectId, element_type- Unique compositeelement_type- Type filteringsource_element_id- Global default trackingdeletedAt- Soft delete queries
Relationships:
project_element_defaults
├── belongsTo projects (as project) ─── CASCADE on delete
├── belongsTo element_type_defaults (as source_element) ─── SET NULL on delete
├── belongsTo users (as createdBy) ─── Audit trail
└── belongsTo users (as updatedBy) ─── Audit trail
Auto-Snapshot on Project Creation
When a new project is created, all global element defaults are automatically copied to the project:
File: backend/src/db/api/projects.ts
static async create(options) {
const project = await super.create(options);
// Auto-snapshot global element defaults to project
try {
const Project_element_defaultsDBApi = require('./project_element_defaults.ts').default;
await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, options);
} catch (error) {
console.error('Failed to snapshot global element defaults:', error);
// Non-fatal - project is still created
}
return project;
}
File: backend/src/db/api/project_element_defaults.ts
static async snapshotGlobalDefaults(projectId, options = {}) {
const globalDefaults = await Element_type_defaultsDBApi.findAll({});
const projectDefaults = await this.MODEL.bulkCreate(
globalDefaults.rows.map((globalDefault) => ({
projectId,
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id, // Track source for updates
snapshot_version: 1,
createdById: options.currentUser?.id,
updatedById: options.currentUser?.id,
})),
{ transaction: options.transaction }
);
return projectDefaults;
}
Reset and Diff Operations
Reset to Global: Restore a project's element default to the current global value
static async resetToGlobalDefault(projectId, element_type, options = {}) {
// Find the global default
const globalDefault = await Element_type_defaultsDBApi.findByType(element_type);
if (!globalDefault) throw new ValidationError('Global default not found');
// Update project default
return this.update(
projectDefault.id,
{
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id,
snapshot_version: projectDefault.snapshot_version + 1,
},
options
);
}
Diff from Global: Check if project settings differ from current global
static async diffFromGlobal(projectId, element_type) {
const projectDefault = await this.findByProjectAndType(projectId, element_type);
const globalDefault = await Element_type_defaultsDBApi.findByType(element_type);
const isDifferent = JSON.stringify(projectDefault?.settings_json) !==
JSON.stringify(globalDefault?.default_settings_json);
return {
isDifferent,
hasGlobalDefault: !!globalDefault,
projectDefault,
globalDefault,
};
}
Page Elements (Inline Storage)
Data Structure
Page elements are stored directly in tour_pages.ui_schema_json.elements[], not in a separate table. This simplifies the data model and eliminates ID remapping during publishing.
Storage Location: tour_pages.ui_schema_json
Constructor copy/paste is handled within this inline element array. Copy stores
the selected element in a constructor-local clipboard, and Paste appends a deep
clone to the active page. All settings are preserved, including position,
dimensions, CSS styles, effects, media URLs, links, navigation targetPageSlug,
and transition fields. The pasted element receives fresh instance IDs for the
element itself and nested gallery, carousel, and info-panel items so the clone is
independent from the source.
interface UISchemaJSON {
elements: UIElement[];
}
interface UIElement {
id: string; // Generated UUID for this element
type: string; // Element type (navigation_next, spot, etc.)
name?: string; // Instance name
sortOrder: number; // Z-order on page
isVisible: boolean; // Visibility toggle
xPercent?: number; // X position (0-100%)
yPercent?: number; // Y position (0-100%)
widthPercent?: number; // Width as percentage
heightPercent?: number; // Height as percentage
rotationDeg?: number; // Rotation in degrees
// ... additional type-specific fields (iconUrl, targetPageSlug, etc.)
}
Standard Element Types:
const ELEMENT_TYPES = [
'navigation_next', // Forward navigation
'navigation_prev', // Back navigation
'spot', // Hotspot/clickable area
'description', // Text description
'gallery', // Image gallery
'carousel', // Image carousel
'logo', // Logo element
'video_player', // Video player
'audio_player', // Audio player
'popup', // Modal popup
'info_panel', // Info panel with images/embeds
];
Benefits of Inline Storage:
- No separate table to manage
- Elements copied automatically with pages during publishing
- Navigation uses
targetPageSlug(consistent across environments) - No ID remapping needed
Style JSON Structure
The style_json field stores CSS properties:
interface StyleJson {
width?: string; // e.g., '24vw'
height?: string; // e.g., '8vh'
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string; // e.g., '0 auto'
padding?: string; // e.g., '0.5rem 0.75rem'
gap?: string;
fontSize?: string; // e.g., '0.875rem'
lineHeight?: string;
fontWeight?: string; // e.g., '500'
border?: string; // e.g., '2px solid currentColor'
borderRadius?: string; // e.g., '8px'
opacity?: string; // CSS opacity stored as 0..1, e.g., '0', '0.5', '0.95', '1'
boxShadow?: string; // e.g., '0 4px 12px rgba(0,0,0,0.1)'
display?: string; // 'block' | 'flex' | 'grid' | etc.
position?: string; // 'static' | 'relative' | 'absolute' | etc.
justifyContent?: string; // 'flex-start' | 'center' | etc.
alignItems?: string; // 'stretch' | 'center' | etc.
textAlign?: string; // 'left' | 'center' | 'right'
zIndex?: string; // e.g., '10'
}
Constructor/defaults editors display opacity as a clamped percentage from 0 to
100 and convert it back to CSS opacity from 0 to 1 before saving.
Opacity belongs to the shared style defaults list, so values configured in
element_type_defaults.default_settings_json or
project_element_defaults.settings_json are applied when new page elements are
created unless the page element overrides opacity locally.
Element zIndex values are normalized to numbers and applied to the outer
absolutely-positioned wrapper used by constructor and runtime rendering. This
lets overlapping page elements stack by CSS panel value instead of only by
creation/render order.
CSS Unit Normalization (Canvas Units)
Style values are automatically normalized to Canvas Units (--cu) for responsive scaling across all viewport sizes. The --cu CSS custom property represents "1 design pixel" and scales proportionally with the viewport.
See: UI Adaptivity System for complete documentation.
Canvas Unit Scaling:
At design resolution (1920×1080 on 1920×1080 viewport): --cu = 1px
At 4K (3840×2160 viewport): --cu = 2px (elements render 2x larger)
At half-resolution (960×540 viewport): --cu = 0.5px (elements render at half size)
General Element Styles (via elementStyles.ts):
| Property | Conversion | Example |
|---|---|---|
| width, minWidth, maxWidth | Canvas units | "200" → "calc(200 * var(--cu, 1px))" |
| height, minHeight, maxHeight | Canvas units | "100" → "calc(100 * var(--cu, 1px))" |
| borderRadius | Canvas units | "12" → "calc(12 * var(--cu, 1px))" |
| fontSize | Canvas units | "16" → "calc(16 * var(--cu, 1px))" |
| border | none | Complex value, preserved as-is |
Legacy Unit Conversion:
| Input | Output |
|---|---|
"24px" |
"calc(24 * var(--cu, 1px))" |
"50vw" |
"calc(960 * var(--cu, 1px))" (50% of 1920) |
"25vh" |
"calc(270 * var(--cu, 1px))" (25% of 1080) |
"1.5rem" |
"calc(24 * var(--cu, 1px))" (1.5 × 16) |
Edge Cases Handled:
- Values already using
var(--cu)→ preserved - Complex values with spaces (e.g.,
"10px 20px") → preserved - CSS functions (e.g.,
"calc(100% - 20px)") → preserved - CSS variables (e.g.,
"var(--spacing)") → preserved - Zero values →
"0"(no unit needed) - Negative values → converted (e.g.,
"-10"→"calc(-10 * var(--cu, 1px))")
JavaScript Usage:
import { toCU } from '../lib/canvasScale';
const style = {
fontSize: toCU(16), // "calc(16 * var(--cu, 1px))"
padding: toCU(12), // "calc(12 * var(--cu, 1px))"
borderRadius: toCU(8), // "calc(8 * var(--cu, 1px))"
};
CSS Inheritance
Inheritable CSS properties (color, fontSize, fontWeight, lineHeight) cascade from parent elements to children via CSS inheritance. This enables setting styles once on the wrapper and having them apply to all child sections.
Wrapper → Child Inheritance:
┌─────────────────────────────────────────────────────────────────┐
│ Element Wrapper (General Element Styles) │
│ • color: #ffffff │
│ • fontSize: 1rem │
│ • fontWeight: 500 │
│ • lineHeight: 1.5 │
├─────────────────────────────────────────────────────────────────┤
│ Child Section (e.g., Gallery Header) │
│ • If galleryHeaderColor is NOT set → inherits #ffffff │
│ • If galleryHeaderColor IS set → uses explicit value │
└─────────────────────────────────────────────────────────────────┘
Implementation Pattern:
// applyIfSet - only applies value if explicitly set (allows CSS inheritance)
applyIfSet(style, 'color', element.galleryHeaderColor);
applyIfSet(style, 'fontSize', element.galleryHeaderFontSize, normalizeRemValue);
// applyWithDefault - applies value or falls back to default (blocks inheritance)
applyWithDefault(style, 'padding', element.galleryHeaderPadding, defaults.padding);
Elements Supporting Inheritance:
| Element | Inheritable From Wrapper |
|---|---|
| NavigationElement | color, fontSize, fontWeight |
| PopupElement | color, fontSize, fontWeight |
| DescriptionElement | color, fontSize, fontWeight (title & text sections) |
| GalleryElement | color, fontSize, fontWeight (all sections: header, title, spans, cards) |
| InfoPanelElement | color, fontSize, fontWeight (all sections: header, title, text, spans, cards) |
Content JSON Structure
The content_json field stores type-specific content:
Navigation:
{
iconUrl?: string;
navLabel?: string;
navLabelFontFamily?: string; // Font key for label text
navType?: 'forward' | 'back';
navDisabled?: boolean;
targetPageSlug?: string; // Slug-based navigation (consistent across environments)
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
}
Tooltip:
{
iconUrl?: string;
tooltipTitle?: string;
tooltipText?: string;
tooltipTitleFontFamily?: string;
tooltipTextFontFamily?: string;
}
Description:
{
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
descriptionTextFontSize?: string;
descriptionTitleFontFamily?: string;
descriptionTextFontFamily?: string;
descriptionTitleColor?: string;
descriptionTextColor?: string;
// Background color is controlled via CSS Styles tab (backgroundColor property)
}
Gallery:
{
galleryCards?: Array<{
id: string;
imageUrl: string;
title: string;
description: string;
}>;
galleryHeaderImageUrl?: string;
galleryTitle?: string;
galleryInfoSpans?: Array<{
id: string;
text: string;
}>;
galleryColumns?: number;
galleryTitleFontFamily?: string;
galleryTextFontFamily?: string; // Font for card titles, descriptions, and info spans
// Gallery header dimension properties
galleryHeaderWidth?: string; // e.g., "100%", "80%", "300px"
galleryHeaderHeight?: string; // e.g., "200px", "20vh", "auto"
galleryHeaderMinHeight?: string; // e.g., "150px"
galleryHeaderMaxHeight?: string; // e.g., "400px", "50vh"
// Gallery card dimension properties
galleryCardWidth?: string; // e.g., "auto", "100%", "250px"
galleryCardHeight?: string; // e.g., "auto", "200px"
galleryCardMinHeight?: string; // e.g., "150px" (default: "40px")
galleryCardAspectRatio?: string; // e.g., "4/3", "1/1", "16/9", "auto" (default: "4/3")
// Gallery carousel navigation icons/positions
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:
{
carouselSlides?: Array<{
id: string;
imageUrl: string;
caption: string;
}>;
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
carouselCaptionFontFamily?: string;
carouselFullWidth?: boolean; // Full-width background mode
// Button positions (percentage 0-100, for full-width mode)
carouselPrevX?: number;
carouselPrevY?: number;
carouselNextX?: number;
carouselNextY?: number;
// Button dimensions (CSS values like '3vw', '5vh')
carouselPrevWidth?: string;
carouselPrevHeight?: string;
carouselNextWidth?: string;
carouselNextHeight?: string;
}
Media (Video/Audio):
{
mediaUrl?: string;
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
}
Info Panel:
{
// Trigger button positioning and optional sizing
// xPercent, yPercent: position on canvas
// width, height: optional - if not set, trigger sizes based on content (icon or text)
// iconUrl: optional trigger icon
infoPanelTriggerLabel?: string;
infoPanelTriggerFontFamily?: string;
infoPanelDisabled?: boolean;
infoPanelOpenByDefault?: boolean;
// Panel content
panelTitle?: string;
panelText?: string;
// Header section
infoPanelHeaderImageUrl?: string;
infoPanelHeaderText?: string;
// Panel position & styling
panelXPercent?: number; // 0-100
panelYPercent?: number; // 0-100
panelWidth?: string; // e.g., '400'
panelHeight?: string; // e.g., 'auto'
panelBackgroundColor?: string; // e.g., 'rgba(0, 0, 0, 0.85)'
panelBorderRadius?: string;
panelPadding?: string;
panelBackdropBlur?: string;
panelOverlayColor?: string; // Backdrop overlay
panelBorderWidth?: string;
panelBorderColor?: string;
panelBorderStyle?: 'none' | 'solid' | 'dashed' | 'dotted';
infoPanelSectionGap?: string; // Gap between sections
// Section instances (NEW: allows multiple instances of same type)
infoPanelSections?: Array<{
id: string; // Unique section ID
type: 'header' | 'title' | 'text' | 'spans' | 'cards' | 'images';
columns?: number; // Grid columns (spans, cards, images)
gap?: string; // Gap between items
mediaOpenMode?: 'panel' | 'fullscreen';
spans?: InfoPanelInfoSpan[]; // For 'spans' sections
images?: InfoPanelImage[]; // For 'cards' or 'images' sections
text?: string; // For 'text' sections
title?: string; // For 'title' sections
headerImageUrl?: string; // For 'header' sections
headerText?: string; // For 'header' sections
clickAction?: 'target_page' | 'external_url';
targetPageSlug?: string;
externalUrl?: string;
}>;
// Media section settings
infoPanelSelectedImageId?: string; // Restored as selected preview image when present
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';
}
Normalization: Info Panel section instances are normalized when defaults are parsed/merged into canvas elements. Missing section IDs are regenerated, invalid section types fall back to text, and nested spans/images keep supported fields including iconUrl for 360 trigger buttons.
InfoPanelInfoSpan Structure:
{
id: string;
text: string;
iconUrl?: string; // Renders icon instead of text when set
clickAction?: 'target_page' | 'external_url';
targetPageSlug?: string;
externalUrl?: string;
}
InfoPanelImage Structure:
{
id: string;
imageUrl?: string; // Regular image URL (storage key)
videoUrl?: string; // Video URL/storage key
embedUrl?: string; // 360/3D embed URL (direct URL)
caption?: string;
itemType?: 'image' | 'video' | '360';
iconUrl?: string; // Custom icon for 360° trigger
clickAction?: 'target_page' | 'external_url';
targetPageSlug?: string;
externalUrl?: string;
}
Element Types Reference
Navigation Buttons (navigation_next, navigation_prev)
Purpose: Navigate between tour pages with optional video transitions.
Interactive Behavior:
- Click: Navigates to page matching
targetPageSlugif set - Disabled:
navDisabled: truesuppresses navigation in constructor interact mode and runtime presentations; runtime hover/focus/active visuals are preserved - Transition: Plays
transitionVideoUrlduring navigation - Reverse Mode: Back navigation can reverse the transition video automatically (
auto_reverse) or use a separatereverseVideoUrl
Runtime Rendering:
// With icon
<Image src={element.iconUrl} alt="Navigation" fill className="object-contain" />
// Without icon (text label)
<div className="px-4 py-2 bg-white/80 rounded text-black text-sm">
{element.navLabel || (element.type === 'navigation_next' ? 'Next' : 'Back')}
</div>
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
targetPageSlugset
Runtime Rendering:
// With icon
<Image src={element.iconUrl} alt="Description" fill className="object-contain" />
// Without icon (text block)
// Note: Background color is applied to the wrapper via CSS Styles (backgroundColor)
<div className="p-4">
<p style={{ fontSize, fontFamily, color: titleColor }}>{descriptionTitle}</p>
<p style={{ fontSize, fontFamily, color: textColor }}>{descriptionText}</p>
</div>
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:
<div className="grid grid-cols-3 gap-2 p-2 bg-black/50 rounded">
{galleryCards.map((card) => (
<div key={card.id} className="relative aspect-square">
<Image src={card.imageUrl} alt={card.title} fill className="object-cover rounded" />
</div>
))}
</div>
Key Properties:
| Property | Type | Description |
|---|---|---|
galleryCards |
GalleryCard[] | Array of gallery items |
GalleryCard Structure:
{
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 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:
// Normal mode: inline carousel
<div className="relative w-full h-full">
<Image src={currentSlide.imageUrl} alt={currentSlide.caption} fill className="object-cover rounded" />
{/* Navigation buttons at fixed positions */}
</div>
// 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:
{
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 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
mediaAutoplayis true - Loop: Repeats continuously if
mediaLoopis true
Runtime Rendering:
<video
src={element.mediaUrl}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
className="w-full h-full object-cover rounded"
/>
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
mediaAutoplayis true
Runtime Rendering:
<audio
src={element.mediaUrl}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
className="w-full"
/>
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: truesuppresses panel opening in constructor interact mode and runtime presentations; runtime hover/focus/active visuals are preserved - Default State:
infoPanelOpenByDefault: trueopens 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
activeInfoPanelstate. 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:panelopens the item in the side preview panel, andfullscreenopens the section's image/video/360 items in the shared fullscreen gallery overlay. Per-itemclickActioncan override this withtarget_pageorexternal_url. - Use as Screen Background: Media items with
useAsBackgroundreplace the current page background with their image, video, or 360 embed content. This uses the same unified background renderer astour_pages.background_image_url,background_video_url, andbackground_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.imagesin 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;panelopens the 360 embed in the side preview panel andfullscreenopens 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
buildChromeFreeEmbedUrlbefore rendering. For Kuula embeds, internal viewer chrome that duplicates platform controls is disabled withfs=0while 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
hoverRevealeffect (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:
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:
// Trigger button (uses hover reveal from Effects)
<button onClick={openPanel}>
{element.iconUrl ? (
<img src={element.iconUrl} />
) : (
<span>{element.infoPanelTriggerLabel || 'Info'}</span>
)}
</button>
// Panel overlay (positioned independently)
<InfoPanelOverlay
element={element}
onClose={closePanel}
onImageClick={openDetailPanel}
onNavigateToPage={navigateBySlug}
onOpenExternalUrl={openExternalUrl}
/>
// Image detail panel (for cards and 360° embeds)
<ImageDetailPanel
element={element}
image={selectedImage}
onClose={closeDetail}
cssVars={cssVars}
/>
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:
<iframe id="tour-frame" src="https://example.com/p/project" allow="fullscreen" allowfullscreen></iframe>
<script>
window.addEventListener('message', async (event) => {
if (event.data?.type !== 'tour-builder:request-fullscreen') return;
await document.getElementById('tour-frame')?.requestFullscreen();
});
</script>
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 |
| Title | `panelTitle[Color |
| Text | `panelText[Color |
| Spans | `infoPanelSpan[BackgroundColor |
| Cards | `infoPanelCard[BackgroundColor |
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
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:
- Check if element has
targetPageSlug - Resolve slug to page ID from loaded pages array
- Determine direction (
isBack) fromnavTypeor element type - If
transitionVideoUrlexists, play transition overlay - Otherwise, wait for target page images to load
- Switch to target page
- 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:
GET /api/element-type-defaults?limit=1000&page=1&sort=asc&field=sort_order
Update Request:
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:
POST /api/project-element-defaults/:id/reset
Diff from Global Response:
{
"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
// 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<string, unknown>;
}
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:
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:
// 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' },
];
<ElementSettingsTabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={SETTINGS_TABS}
/>
// Compact tabs for constructor sidebar
import { ElementSettingsTabsCompact } from '../../components/ElementSettings';
<ElementSettingsTabsCompact
activeTab={elementEditorTab}
onTabChange={setElementEditorTab}
tabs={SETTINGS_TABS}
/>
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
// 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:
import {
ElementSettingsTabsCompact,
StyleSettingsSectionCompact,
EffectsSettingsSectionCompact,
extractNumericValue,
} from '../../components/ElementSettings';
const [elementEditorTab, setElementEditorTab] = useState<'general' | 'css' | 'effects'>('general');
// Tab navigation
<ElementSettingsTabsCompact
activeTab={elementEditorTab}
onTabChange={setElementEditorTab}
tabs={[
{ id: 'general', label: 'General' },
{ id: 'css', label: 'CSS Styles' },
{ id: 'effects', label: 'Effects' },
]}
/>
// CSS Styles tab content
{elementEditorTab === 'css' && (
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
// ... other CSS properties
}}
onChange={(prop, value) => {
// 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:
// 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
// Handles both settings_json (project) and default_settings_json (global)
function normalizeElementDefault(row: Record<string, unknown>): 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<CanvasElement> = {};
if (typeof rawSettings === 'string') {
try { settings = JSON.parse(rawSettings); } catch { settings = {}; }
} else if (rawSettings && typeof rawSettings === 'object') {
settings = rawSettings as Partial<CanvasElement>;
}
return { id: String(row.id || ''), element_type: elementType, settings };
}
// Build element type to defaults lookup map
function buildElementDefaultsMap(defaults: NormalizedElementDefault[]) {
const map: Partial<Record<CanvasElementType, Partial<CanvasElement>>> = {};
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:
// 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:
// 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
// Merge instance values with defaults (from project scope)
const mergeElementWithDefaults = (
element: CanvasElement,
defaults?: Partial<CanvasElement>,
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:
# Filter by project UUID
GET /api/project-element-defaults?projectId=<uuid>
# Filter by project name (case-insensitive)
GET /api/project-element-defaults?project=<project-name>
# 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:
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:
- Extracts all asset URLs from element configurations using
extractPageLinksAndElements() - Fetches S3 presigned URLs via
POST /api/file/presign(max 50 per batch, 1-hour expiry) - Downloads assets directly from S3 and stores in Cache API (dual key: download URL + storage key)
- Creates blob URLs and stores in
readyBlobUrlsRef(dual key: download URL + storage key) for O(1) instant lookup - Pre-decodes images before page transitions using the blob URLs
- 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 <img> tags for blob URLs instead of Next.js <Image> 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.jsbackfills existing projects - Migration
20260326171017-add-missing-element-type-defaults.jsadds 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 forsettings_jsonin DB)project_element_defaults:settings_json(direct)
Mitigation: The normalizeElementDefault() utility in types/constructor.ts handles both:
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
// 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:
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
<span style={labelFontStyle}>{element.navLabel}</span>
Settings Component Pattern
Font selectors use font.key as the option value:
import { FONT_OPTIONS } from '../../lib/fonts';
<select value={navLabelFontFamily} onChange={...}>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
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 charsname: 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