39948-vm/documentation/ui-elements.md
2026-07-03 16:11:24 +02:00

110 KiB
Raw Blame History

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:

  1. Global Scope (element_type_defaults) - Platform-wide default configurations for element types
  2. Project Scope (project_element_defaults) - Project-specific settings snapshots/overrides
  3. 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 queries
  • deletedAt - 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.

{
  label: 'Gallery',
  galleryCards: [
    { imageUrl: '', title: 'Card 1', description: '' }
  ],
  appearDelaySec: 0,
  appearDurationSec: null
}
{
  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
}
{
  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 lookup
  • projectId, element_type - Unique composite
  • element_type - Type filtering
  • source_element_id - Global default tracking
  • deletedAt - 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 targetPageSlug if set
  • Disabled: navDisabled: true suppresses navigation in constructor interact mode and runtime presentations; runtime hover/focus/active visuals are preserved
  • Transition: Plays transitionVideoUrl during navigation
  • Reverse Mode: Back navigation can reverse the transition video automatically (auto_reverse) or use a separate reverseVideoUrl

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 targetPageSlug set

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

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.

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 mediaAutoplay is true
  • Loop: Repeats continuously if mediaLoop is 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 mediaAutoplay is 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: true suppresses panel opening in constructor interact mode and runtime presentations; runtime hover/focus/active visuals are preserved
  • Default State: infoPanelOpenByDefault: true opens every enabled matching Info Panel automatically when the page renders in constructor interact mode or runtime presentations
  • Multiple Open Panels: Runtime and constructor interact mode track open Info Panels by element ID. Image detail panels, fullscreen gallery state, and runtime media-section selected image state are scoped to the originating Info Panel ID, so multiple default-open panels can coexist without sharing detail/gallery state. Only one shared fullscreen backdrop is rendered for the group; backdrop clicks close the whole group, while each panel close button closes only that panel.
  • Constructor Edit Preview: In edit mode the Info Panel overlay is rendered only from the selected Info Panel element, not from runtime activeInfoPanel state. The Info Panel and Image Detail preview surfaces use non-blocking pointer events so canvas elements remain selectable/draggable; only the overlay drag handles stay interactive for panel positioning.
  • Panel Backdrop Click: Closes the panel
  • Card/Thumbnail Click: Uses section-level mediaOpenMode: panel opens the item in the side preview panel, and fullscreen opens the section's image/video/360 items in the shared fullscreen gallery overlay. Per-item clickAction can override this with target_page or external_url.
  • Use as Screen Background: Media items with useAsBackground replace the current page background with their image, video, or 360 embed content. This uses the same unified background renderer as tour_pages.background_image_url, background_video_url, and background_embed_url; destination override controls are disabled while this checkbox is enabled.
  • Video Thumbnail Click: Video items use videoUrl; Info Panel thumbnails render a muted first-frame video preview with a play badge instead of a generic placeholder, and open in the configured panel/fullscreen destination with native video controls
  • Media Item Ordering: Info Panel media sections render section.images in stored order, mixing image/video/360 items without regrouping by media type, so the presentation order matches the constructor sidebar order.
  • 360° Trigger Click: Uses the same section-level mediaOpenMode; panel opens the 360 embed in the side preview panel and fullscreen opens it in the fullscreen gallery overlay without rendering runtime top-right controls or granting iframe fullscreen permission
  • 360° Embed Chrome: 360 iframe URLs are normalized through buildChromeFreeEmbedUrl before rendering. For Kuula embeds, internal viewer chrome that duplicates platform controls is disabled with fs=0 while preserving playback/autorotate parameters from the source URL.
  • Header/Title/Text/Span Click: Can target an internal page or an external URL when configured
  • Hover Reveal: Trigger can use hoverReveal effect (appears on hover)
  • Persist on Click: With hoverPersistOnClick, trigger stays visible after click

Section Types:

The Info Panel uses a section-based layout with dynamic ordering. Each section instance has its own ID, settings, and data:

Section Description Per-Instance Data
header Image header with text overlay headerImageUrl, headerText
title Panel title text title
text Body text content text
spans Info badges grid spans[], columns, gap
cards Image cards grid images[], columns, gap
images Media viewer images[]

columns is normalized on load from number-like values, including strings such as "3", and clamped to the supported 1..6 range before rendering.

Click Destinations:

Scope Supported Destinations Fields
Cards/media section panel, fullscreen mediaOpenMode
Image/card/video/360 item override target_page, external_url, screen background clickAction, targetPageSlug, externalUrl, imageUrl, videoUrl, embedUrl, useAsBackground
Header/title/text section target_page, external_url clickAction, targetPageSlug, externalUrl
Span item target_page, external_url clickAction, targetPageSlug, externalUrl

Internal Info Panel links use targetPageSlug and are included in extractPageLinksAndElements, so connected pages participate in neighbor preloading. Info Panel image/video/icon assets nested inside infoPanelSections are also recursively extracted for preloading.

Multiple Section Instances:

Unlike Gallery, Info Panel allows multiple instances of the same section type:

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:

  1. Check if element has targetPageSlug
  2. Resolve slug to page ID from loaded pages array
  3. Determine direction (isBack) from navType or element type
  4. If transitionVideoUrl exists, play transition overlay
  5. Otherwise, wait for target page images to load
  6. Switch to target page
  7. Add to page history

API Endpoints

Global Scope (element_type_defaults)

Route: backend/src/routes/element_type_defaults.ts

Uses checkCrudPermissions('element_type_defaults').

Method Endpoint Permission Description
GET /api/element-type-defaults READ_PAGE_ELEMENTS List all element types
GET /api/element-type-defaults/:id READ_PAGE_ELEMENTS Get element type details
POST /api/element-type-defaults CREATE_PAGE_ELEMENTS Create new type (admin)
PUT /api/element-type-defaults/:id UPDATE_PAGE_ELEMENTS Update defaults
DELETE /api/element-type-defaults/:id DELETE_PAGE_ELEMENTS Delete type (admin)
GET /api/element-type-defaults/autocomplete READ_PAGE_ELEMENTS Autocomplete search
GET /api/element-type-defaults/count READ_PAGE_ELEMENTS Count types

Backwards Compatibility: The old route /api/ui-elements is aliased to /api/element-type-defaults.

List Request:

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:

  1. Extracts all asset URLs from element configurations using extractPageLinksAndElements()
  2. Fetches S3 presigned URLs via POST /api/file/presign (max 50 per batch, 1-hour expiry)
  3. Downloads assets directly from S3 and stores in Cache API (dual key: download URL + storage key)
  4. Creates blob URLs and stores in readyBlobUrlsRef (dual key: download URL + storage key) for O(1) instant lookup
  5. Pre-decodes images before page transitions using the blob URLs
  6. Manages concurrent downloads (max 3)

Storage Key Mapping: Assets are cached by canonical storage key (e.g., assets/project/icon.png) in addition to download URLs. This ensures cache hits even when presigned URL signatures change.

Blob URL Rendering: When displaying preloaded assets, use native <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.js backfills existing projects
  • Migration 20260326171017-add-missing-element-type-defaults.js adds spot, logo, popup types
  • Constructor handles empty defaults gracefully

4. Field Name Aliasing

Issue: Different tables use different field names for the same data:

  • element_type_defaults: default_settings_json (alias for settings_json in DB)
  • project_element_defaults: settings_json (direct)

Mitigation: The normalizeElementDefault() utility in types/constructor.ts handles both:

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 chars
  • name: Required, 1-255 chars

Project scope (project_element_defaults):

  • projectId: Required FK (validated by Sequelize)
  • element_type: Required, 1-100 chars
  • Unique constraint: (projectId, element_type)

Page elements (in ui_schema_json):

  • Validated at application level in constructor
  • type: Required, must be a valid element type
  • Position values (xPercent, yPercent): 0-100 range