lumina-slides 9.0.5 → 9.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +63 -0
  2. package/dist/lumina-slides.js +21750 -19334
  3. package/dist/lumina-slides.umd.cjs +223 -223
  4. package/dist/style.css +1 -1
  5. package/package.json +1 -1
  6. package/src/components/LandingPage.vue +1 -1
  7. package/src/components/LuminaDeck.vue +237 -232
  8. package/src/components/base/LuminaElement.vue +2 -0
  9. package/src/components/layouts/LayoutFeatures.vue +125 -123
  10. package/src/components/layouts/LayoutFlex.vue +212 -212
  11. package/src/components/layouts/LayoutStatement.vue +5 -2
  12. package/src/components/layouts/LayoutSteps.vue +110 -108
  13. package/src/components/parts/FlexHtml.vue +65 -65
  14. package/src/components/parts/FlexImage.vue +81 -81
  15. package/src/components/site/SiteDocs.vue +3313 -3314
  16. package/src/components/site/SiteExamples.vue +66 -66
  17. package/src/components/studio/EditorLayoutChart.vue +18 -0
  18. package/src/components/studio/EditorLayoutCustom.vue +18 -0
  19. package/src/components/studio/EditorLayoutVideo.vue +18 -0
  20. package/src/components/studio/LuminaStudioEmbed.vue +68 -0
  21. package/src/components/studio/StudioEmbedRoot.vue +19 -0
  22. package/src/components/studio/StudioInspector.vue +1113 -7
  23. package/src/components/studio/StudioJsonEditor.vue +10 -3
  24. package/src/components/studio/StudioSettings.vue +658 -7
  25. package/src/components/studio/StudioToolbar.vue +26 -7
  26. package/src/composables/useElementState.ts +12 -1
  27. package/src/composables/useFlexLayout.ts +128 -128
  28. package/src/core/Lumina.ts +174 -113
  29. package/src/core/animationConfig.ts +10 -0
  30. package/src/core/elementController.ts +18 -0
  31. package/src/core/elementResolver.ts +4 -2
  32. package/src/core/schema.ts +503 -503
  33. package/src/core/store.ts +465 -465
  34. package/src/core/types.ts +26 -11
  35. package/src/index.ts +2 -2
  36. package/src/utils/prepareDeckForExport.ts +47 -0
  37. package/src/utils/templateInterpolation.ts +52 -52
  38. package/src/views/DeckView.vue +313 -313
@@ -1,6 +1,7 @@
1
- import { createApp, App as VueApp, watch } from 'vue';
1
+ import { createApp, App as VueApp, watch, ref, type Ref } from 'vue';
2
2
  import LuminaDeck from '../components/LuminaDeck.vue';
3
3
  import LuminaStudio from '../components/studio/LuminaStudio.vue';
4
+ import StudioEmbedRoot from '../components/studio/StudioEmbedRoot.vue';
4
5
  import LuminaSpeakerNotes from '../components/LuminaSpeakerNotes.vue';
5
6
  import LayoutStatement from '../components/layouts/LayoutStatement.vue';
6
7
  import LayoutHalf from '../components/layouts/LayoutHalf.vue';
@@ -18,7 +19,7 @@ import LuminaElement from '../components/base/LuminaElement.vue';
18
19
  import { createStore, StoreKey, LuminaStore } from './store';
19
20
  import { resolveTransitionConfig } from './animationConfig';
20
21
  import { computeStagger, resolveAnimationFromInput } from '../animation';
21
- import { createElementController } from './elementController';
22
+ import { createElementController, createNoopElementController } from './elementController';
22
23
  import { parsePath, resolveId, getElementIds } from './elementResolver';
23
24
  import { elemId } from './elementId';
24
25
  import type { ElementPath } from './elementResolver';
@@ -34,8 +35,8 @@ import '../style/main.css';
34
35
  * @description
35
36
  * Mount a slide deck into a DOM element, load/patch deck data, subscribe to events (slideChange, action),
36
37
  * and export state for LLM context. Vanilla JS: use `engine.load(deck)`, `engine.element(id)`, `engine.on(...)`.
37
- * For reveal-on-demand: set `meta.initialElementState` and `meta.elementControl.defaultVisible: false`;
38
- * register `ready` and `slideChange` before `load`; then `engine.element(id).show()` or `revealInSequence`.
38
+ * Elements start hidden by default and cascade-reveal on slide enter. Set `elementControl.defaultVisible: true`
39
+ * to show all, or `slide.reveal: false` for manual control. Use `engine.element(id).show()` or `revealInSequence`.
39
40
  * `engine.element(id).animate()` and `getElementById` require the element's slide to be mounted (no-op/null otherwise).
40
41
  *
41
42
  * @example
@@ -44,9 +45,7 @@ import '../style/main.css';
44
45
  * import "lumina-slides/style.css";
45
46
  *
46
47
  * const engine = new Lumina("#app", { theme: "ocean", loop: true });
47
- * engine.on("ready", () => engine.revealInSequence(0));
48
- * engine.on("slideChange", (e) => engine.revealInSequence(e.index));
49
- * engine.load({ meta: { title: "Demo", elementControl: { defaultVisible: false } }, slides: [
48
+ * engine.load({ meta: { title: "Demo" }, slides: [
50
49
  * { type: "statement", title: "Hello", subtitle: "World" }
51
50
  * ]});
52
51
  * engine.on("action", (p) => console.log("clicked", p));
@@ -59,9 +58,16 @@ import '../style/main.css';
59
58
  * @see IMPLEMENTATION.md
60
59
  * @see AGENTS.md
61
60
  */
61
+ const DEFAULT_EMBED_DECK: Deck = {
62
+ meta: { title: 'Untitled' },
63
+ slides: [{ type: 'statement', title: 'New Slide', subtitle: '' }]
64
+ };
65
+
62
66
  export class Lumina {
63
67
  private app: VueApp;
64
- private store: LuminaStore;
68
+ private store: LuminaStore | null = null;
69
+ /** When studioEmbed is true, deck is driven by this ref; store is not used. */
70
+ private _embedDeckRef: Ref<Deck | null> | null = null;
65
71
 
66
72
  /** Key-value store: `engine.data.get/set/has/delete/clear/keys`. Persists across deck loads. */
67
73
  public readonly data: LuminaDataStore;
@@ -86,88 +92,93 @@ export class Lumina {
86
92
  // Generate unique channel ID for this instance
87
93
  this.speakerChannelId = `lumina-speaker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
88
94
 
89
- // Create isolated store for this instance
90
- this.store = createStore(options);
91
-
92
- const dataApi: LuminaDataStore = {
93
- get: (k) => this.store.getUserData(k),
94
- set: (k, v) => { this.store.setUserData(k, v); return dataApi; },
95
- has: (k) => this.store.hasUserData(k),
96
- delete: (k) => this.store.deleteUserData(k),
97
- clear: () => this.store.clearUserData(),
98
- keys: () => Object.keys(this.store.state.userData)
99
- };
100
- this.data = dataApi;
101
-
102
- // Apply initial theme
103
- if (options.theme) {
104
- ThemeManager.inject(options.theme);
105
- }
106
-
107
- // Initialize Vue App
108
- this.app = options.studio ? createApp(LuminaStudio) : createApp(LuminaDeck);
109
-
110
- // Dependency Injection
111
- this.app.provide(StoreKey, this.store);
112
- this.app.provide('LuminaEngine', this);
113
-
114
- // Watch for state changes and emit public events
115
- watch(() => this.store.state.currentIndex, (newIndex, oldIndex) => {
116
- this.store.resetElementStateForSlide(newIndex);
117
- const deck = this.store.state.deck;
118
- const currentSlide = deck?.slides?.[newIndex];
119
- if (currentSlide) {
120
- bus.emit('slideChange', {
121
- index: newIndex,
122
- previousIndex: oldIndex ?? 0,
123
- slide: currentSlide
124
- });
125
- const rev = deck?.meta?.reveal ?? currentSlide?.reveal;
126
- if (rev && typeof rev === 'object') {
127
- this.revealInSequence(newIndex, this.getRevealOptionsFromDeck(deck!, newIndex));
95
+ const isStudioEmbed = !!options.studioEmbed;
96
+
97
+ if (isStudioEmbed) {
98
+ this._embedDeckRef = ref<Deck | null>(
99
+ options.initialDeck && Array.isArray(options.initialDeck.slides)
100
+ ? options.initialDeck
101
+ : DEFAULT_EMBED_DECK
102
+ );
103
+ this.data = {
104
+ get: () => '',
105
+ set: () => this.data!,
106
+ has: () => false,
107
+ delete: () => false,
108
+ clear: () => {},
109
+ keys: () => []
110
+ } as LuminaDataStore;
111
+
112
+ if (options.theme) ThemeManager.inject(options.theme);
113
+
114
+ this.app = createApp(StudioEmbedRoot, { deckRef: this._embedDeckRef });
115
+ } else {
116
+ this.store = createStore(options);
117
+ const dataApi: LuminaDataStore = {
118
+ get: (k) => this.store!.getUserData(k),
119
+ set: (k, v) => { this.store!.setUserData(k, v); return dataApi; },
120
+ has: (k) => this.store!.hasUserData(k),
121
+ delete: (k) => this.store!.deleteUserData(k),
122
+ clear: () => this.store!.clearUserData(),
123
+ keys: () => Object.keys(this.store!.state.userData)
124
+ };
125
+ this.data = dataApi;
126
+
127
+ if (options.theme) ThemeManager.inject(options.theme);
128
+
129
+ this.app = options.studio ? createApp(LuminaStudio) : createApp(LuminaDeck);
130
+ this.app.provide(StoreKey, this.store);
131
+ this.app.provide('LuminaEngine', this);
132
+
133
+ watch(() => this.store!.state.currentIndex, (newIndex, oldIndex) => {
134
+ this.store!.resetElementStateForSlide(newIndex);
135
+ const deck = this.store!.state.deck;
136
+ const currentSlide = deck?.slides?.[newIndex];
137
+ if (currentSlide) {
138
+ bus.emit('slideChange', {
139
+ index: newIndex,
140
+ previousIndex: oldIndex ?? 0,
141
+ slide: currentSlide
142
+ });
143
+ // Reveal runs from Deck onEnter when the slide has mounted (elements in DOM).
128
144
  }
129
- }
130
- });
145
+ });
131
146
 
132
- // Watch for metadata changes (Theme, Orb, etc.)
133
- watch(() => this.store.state.deck?.meta, (newMeta) => {
134
- if (newMeta) {
135
- // 1. Construct Theme Overrides from Meta
136
- // - meta.themeConfig: full ThemeConfig object (e.g. from theme-custom-example.json)
137
- // - meta.colors, meta.typography, etc.: direct overrides from Studio (take precedence)
138
- const fromThemeConfig = newMeta.themeConfig || {};
139
- const direct: Partial<ThemeConfig> = {
140
- colors: newMeta.colors,
141
- typography: newMeta.typography,
142
- spacing: newMeta.spacing,
143
- borderRadius: newMeta.borderRadius,
144
- effects: newMeta.effects,
145
- components: newMeta.components
146
- };
147
- const base = (fromThemeConfig && typeof fromThemeConfig === 'object' && !Array.isArray(fromThemeConfig) ? fromThemeConfig : {});
148
- const overrides = deepMerge<ThemeConfig>(base as ThemeConfig, direct);
149
-
150
- // Resolve theme: deck.theme (top-level, e.g. from theme-ocean.json) takes precedence over
151
- // meta.theme, then constructor options. Ensures examples and loaded decks keep their theme.
152
- const deck = this.store.state.deck;
153
- const themeOrName = deck?.theme ?? newMeta.theme ?? this.store.state.options?.theme ?? 'default';
154
-
155
- // 2. Dynamic Theme Application
156
- // This ensures that any change in Studio (which updates deck.meta) is immediately reflected
157
- // via CSS variables injected by ThemeManager.
158
- ThemeManager.inject(themeOrName, overrides);
159
-
160
- // 3. Fallback/Direct variable handling if needed (e.g. for specific legacy props)
161
- if (newMeta.orbColor) {
162
- // Update the primary color variable as a fallback or if specifically mapped
163
- document.documentElement.style.setProperty('--lumina-colors-primary', newMeta.orbColor);
147
+ watch(() => this.store!.state.deck?.meta, (newMeta) => {
148
+ if (newMeta) {
149
+ const fromThemeConfig = newMeta.themeConfig || {};
150
+ const direct: Partial<ThemeConfig> = {
151
+ colors: newMeta.colors,
152
+ typography: newMeta.typography,
153
+ spacing: newMeta.spacing,
154
+ borderRadius: newMeta.borderRadius,
155
+ effects: newMeta.effects,
156
+ components: newMeta.components
157
+ };
158
+ const base = (fromThemeConfig && typeof fromThemeConfig === 'object' && !Array.isArray(fromThemeConfig) ? fromThemeConfig : {});
159
+ const overrides = deepMerge<ThemeConfig>(base as ThemeConfig, direct);
160
+ const deck = this.store!.state.deck;
161
+ const themeOrName = deck?.theme ?? newMeta.theme ?? this.store!.state.options?.theme ?? 'default';
162
+ ThemeManager.inject(themeOrName, overrides);
163
+ if (newMeta.orbColor) {
164
+ document.documentElement.style.setProperty('--lumina-colors-primary', newMeta.orbColor);
165
+ }
166
+ bus.emit('themeChange', { theme: typeof themeOrName === 'string' ? themeOrName : 'custom' });
164
167
  }
168
+ }, { deep: true });
169
+
170
+ bus.on('action', (payload) => {
171
+ if (payload.type === 'slide-update' && typeof payload.slideIndex === 'number') {
172
+ const { slideIndex, patch } = payload;
173
+ if (patch && typeof patch === 'object') {
174
+ if ('nodes' in patch) this.store!.updateNode(`slides.${slideIndex}.nodes`, patch.nodes);
175
+ if ('edges' in patch) this.store!.updateNode(`slides.${slideIndex}.edges`, patch.edges);
176
+ }
177
+ }
178
+ this.store!.recordAction(payload);
179
+ });
180
+ }
165
181
 
166
- bus.emit('themeChange', { theme: typeof themeOrName === 'string' ? themeOrName : 'custom' });
167
- }
168
- }, { deep: true });
169
-
170
- // Register Core Components
171
182
  this.app.component('layout-statement', LayoutStatement);
172
183
  this.app.component('layout-half', LayoutHalf);
173
184
  this.app.component('layout-features', LayoutFeatures);
@@ -182,26 +193,9 @@ export class Lumina {
182
193
  this.app.component('layout-free', LayoutFree);
183
194
  this.app.component('LuminaElement', LuminaElement);
184
195
 
185
- // Internal Event Listeners
186
- bus.on('action', (payload) => {
187
- // Feature: Real-time Slide Updates (e.g. from Diagram Layout)
188
- if (payload.type === 'slide-update' && typeof payload.slideIndex === 'number') {
189
- const { slideIndex, patch } = payload;
190
- if (patch && typeof patch === 'object') {
191
- if ('nodes' in patch) {
192
- this.store.updateNode(`slides.${slideIndex}.nodes`, patch.nodes);
193
- }
194
- if ('edges' in patch) {
195
- this.store.updateNode(`slides.${slideIndex}.edges`, patch.edges);
196
- }
197
- }
198
- }
199
- this.store.recordAction(payload);
200
- });
201
-
202
196
  this.app.mount(this.selector);
203
197
 
204
- this.syncSpeakerNotes();
198
+ if (!isStudioEmbed) this.syncSpeakerNotes();
205
199
  }
206
200
 
207
201
  /**
@@ -219,13 +213,14 @@ export class Lumina {
219
213
  return;
220
214
  }
221
215
  try {
222
- this.store.loadDeck(deckData);
223
- bus.emit('ready', deckData);
224
- const rev = deckData.meta?.reveal ?? deckData.slides?.[0]?.reveal;
225
- if (rev && typeof rev === 'object') {
226
- this.revealInSequence(0, this.getRevealOptionsFromDeck(deckData, 0));
216
+ if (this._embedDeckRef) {
217
+ this._embedDeckRef.value = deckData;
218
+ } else if (this.store) {
219
+ this.store.loadDeck(deckData);
220
+ // Reveal runs from Deck onEnter when the first slide has mounted.
221
+ this.syncSpeakerNotes();
227
222
  }
228
- this.syncSpeakerNotes();
223
+ bus.emit('ready', deckData);
229
224
  } catch (e) {
230
225
  bus.emit('error', e instanceof Error ? e : new Error(String(e)));
231
226
  }
@@ -241,6 +236,7 @@ export class Lumina {
241
236
  * engine.patch({ meta: { title: "Updated Title" } });
242
237
  */
243
238
  public patch(diff: Partial<Deck>) {
239
+ if (!this.store) return;
244
240
  this.store.patchDeck(diff);
245
241
  bus.emit('patch', { diff: diff ?? {} });
246
242
  this.syncSpeakerNotes();
@@ -256,6 +252,7 @@ export class Lumina {
256
252
  * // "User is on slide 3 (features). Session: clicked 'Learn More' -> navigated next."
257
253
  */
258
254
  public exportState() {
255
+ if (!this.store) return Object.freeze({ status: 'active' as const, currentSlide: null, narrative: '', engagementLevel: 'Low Engagement', history: [] });
259
256
  const { currentIndex, deck, actionHistory } = this.store.state;
260
257
  const slide = deck?.slides[currentIndex];
261
258
  const context = actionHistory.map(a => `User ${a.type}ed on ${a.label || a.value}`).join(' -> ');
@@ -323,9 +320,9 @@ export class Lumina {
323
320
  bus.clear();
324
321
  }
325
322
 
326
- /** Zero-based index of the currently visible slide. */
323
+ /** Zero-based index of the currently visible slide. In studioEmbed mode returns 0. */
327
324
  public get currentSlideIndex() {
328
- return this.store.state.currentIndex;
325
+ return this.store ? this.store.state.currentIndex : 0;
329
326
  }
330
327
 
331
328
  /**
@@ -333,6 +330,7 @@ export class Lumina {
333
330
  * @param index - Zero-based slide index. Clamped to [0, slides.length - 1].
334
331
  */
335
332
  public goTo(index: number) {
333
+ if (!this.store) return;
336
334
  const from = this.store.state.currentIndex;
337
335
  this.store.goto(index);
338
336
  if (this.store.state.currentIndex !== from) {
@@ -343,6 +341,7 @@ export class Lumina {
343
341
 
344
342
  /** Advances to the next slide (or first if loop and at end). */
345
343
  public next() {
344
+ if (!this.store) return;
346
345
  const from = this.store.state.currentIndex;
347
346
  this.store.next();
348
347
  if (this.store.state.currentIndex !== from) {
@@ -353,6 +352,7 @@ export class Lumina {
353
352
 
354
353
  /** Goes to the previous slide (or last if loop and at start). */
355
354
  public prev() {
355
+ if (!this.store) return;
356
356
  const from = this.store.state.currentIndex;
357
357
  this.store.prev();
358
358
  if (this.store.state.currentIndex !== from) {
@@ -424,6 +424,7 @@ export class Lumina {
424
424
  public element(id: string): ElementController;
425
425
  public element(slideIndex: number, path: string | ElementPath): ElementController;
426
426
  public element(idOrSlide: string | number, path?: string | ElementPath): ElementController {
427
+ if (!this.store) return createNoopElementController(this, typeof idOrSlide === 'string' ? idOrSlide : '');
427
428
  if (typeof idOrSlide === 'number') {
428
429
  const slide = this.store.state.deck?.slides[idOrSlide];
429
430
  const p = parsePath(path!);
@@ -448,7 +449,7 @@ export class Lumina {
448
449
  * @see elements
449
450
  */
450
451
  public elementInCurrent(path: string | ElementPath): ElementController {
451
- const i = this.store.state.currentIndex ?? 0;
452
+ const i = this.store ? this.store.state.currentIndex ?? 0 : 0;
452
453
  return this.element(i, path);
453
454
  }
454
455
 
@@ -473,6 +474,7 @@ export class Lumina {
473
474
  * @see DeckMeta.initialElementState
474
475
  */
475
476
  public elements(slideIndex?: number): string[] {
477
+ if (!this.store) return [];
476
478
  const i = slideIndex ?? this.store.state.currentIndex ?? 0;
477
479
  const slide = this.store.state.deck?.slides[i];
478
480
  if (!slide) return [];
@@ -577,6 +579,62 @@ export class Lumina {
577
579
  return { ...a, ...b };
578
580
  }
579
581
 
582
+ /**
583
+ * Options for the default cascade when no meta.reveal or slide.reveal is configured.
584
+ * Uses defaultCascadeDelayMs and defaultCascadeDuration from config; excludes the slide root.
585
+ */
586
+ private getDefaultCascadeOptions(index: number): RevealInSequenceOptions {
587
+ if (!this.store) return {};
588
+ const deck = this.store.state.deck;
589
+ const slide = deck?.slides?.[index] as BaseSlideData | undefined;
590
+ const cfg = resolveTransitionConfig(this.store, slide ?? undefined);
591
+ const slideRootId = slide ? resolveId(slide, index, ['slide']) : undefined;
592
+ return {
593
+ delayMs: cfg.defaultCascadeDelayMs,
594
+ duration: cfg.defaultCascadeDuration,
595
+ ...(slideRootId ? { exclude: [slideRootId] as readonly string[] } : {}),
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Runs reveal for a slide if configured (default cascade, meta.reveal, or slide.reveal).
601
+ * Call when the slide has mounted (e.g. from Deck's onEnter) so elements exist in the DOM.
602
+ * Returns the reveal promise or undefined if no reveal runs.
603
+ */
604
+ public runRevealForSlideIfNeeded(slideIndex?: number): Promise<void> | undefined {
605
+ if (!this.store) return undefined;
606
+ const i = slideIndex ?? this.currentSlideIndex;
607
+ const opts = this.getRevealDecision(i);
608
+ if (opts === null) return undefined;
609
+ return this.revealInSequence(i, opts);
610
+ }
611
+
612
+ /**
613
+ * Decides whether and how to run reveal for a slide. Returns options to pass to revealInSequence,
614
+ * or null if no reveal should run.
615
+ */
616
+ private getRevealDecision(index: number): RevealInSequenceOptions | null {
617
+ const deck = this.store?.state.deck;
618
+ const slide = deck?.slides?.[index];
619
+ if (!slide) return null;
620
+
621
+ const rev = deck?.meta?.reveal ?? (slide as { reveal?: unknown }).reveal;
622
+
623
+ // Explicit opt-out: no reveal
624
+ if (rev === false) return null;
625
+
626
+ // Timeline-driven: no cascade
627
+ if ((slide as { timelineTracks?: unknown }).timelineTracks != null) return null;
628
+
629
+ // Explicit reveal config: use it
630
+ if (rev && typeof rev === 'object') {
631
+ return this.getRevealOptionsFromDeck(deck!, index);
632
+ }
633
+
634
+ // Default: fast cascade
635
+ return this.getDefaultCascadeOptions(index);
636
+ }
637
+
580
638
  /**
581
639
  * Reveals slide elements one by one with optional stagger and animation. Optionally
582
640
  * hides all first (hideFirst: true). Resolves when the sequence has been scheduled
@@ -591,6 +649,7 @@ export class Lumina {
591
649
  * await engine.revealInSequence(0, { only: ['s0-tag', 's0-title'], hideFirst: true });
592
650
  */
593
651
  public revealInSequence(slideIndex?: number, options?: RevealInSequenceOptions): Promise<void> {
652
+ if (!this.store) return Promise.resolve();
594
653
  const i = slideIndex ?? this.currentSlideIndex;
595
654
  let ids = this.elements(i);
596
655
  const opts = options ?? {};
@@ -677,6 +736,7 @@ export class Lumina {
677
736
  * slider.oninput = () => engine.seekTo(parseFloat(slider.value));
678
737
  */
679
738
  public seekTo(progress: number, slideIndex?: number): this {
739
+ if (!this.store) return this;
680
740
  const i = slideIndex ?? this.currentSlideIndex;
681
741
  const slide = this.store.state.deck?.slides[i];
682
742
  const timeline = slide?.timelineTracks;
@@ -703,6 +763,7 @@ export class Lumina {
703
763
  * // cancel() to stop
704
764
  */
705
765
  public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
766
+ if (!this.store) return [Promise.resolve(), () => {}];
706
767
  const i = slideIndex ?? this.currentSlideIndex;
707
768
  const slide = this.store.state.deck?.slides[i];
708
769
  if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
@@ -89,6 +89,12 @@ export const ANIMATION_DEFAULTS = {
89
89
  revealDuration: 0.45,
90
90
  revealEase: 'power2.out',
91
91
 
92
+ // --- Default cascade (when no meta.reveal or slide.reveal) ---
93
+ /** Delay in ms between elements for implicit default cascade. Default 120. */
94
+ defaultCascadeDelayMs: 120,
95
+ /** Per-element duration (s) for implicit default cascade. Default 0.3. */
96
+ defaultCascadeDuration: 0.3,
97
+
92
98
  // --- elementController.animate ---
93
99
  elementDuration: 0.5,
94
100
  elementEase: 'power2.out',
@@ -133,6 +139,8 @@ export const ANIMATION_DEFAULTS = {
133
139
  * @property {number} revealDelayMs - revealInSequence delay between elements (ms). Default 400.
134
140
  * @property {number} revealDuration - revealInSequence per-element duration (s). Default 0.45.
135
141
  * @property {string} revealEase - revealInSequence ease. Default 'power2.out'.
142
+ * @property {number} defaultCascadeDelayMs - Default cascade delay between elements (ms). Default 120.
143
+ * @property {number} defaultCascadeDuration - Default cascade per-element duration (s). Default 0.3.
136
144
  * @property {number} elementDuration - element().animate() default duration (s). Default 0.5.
137
145
  * @property {string} elementEase - element().animate() default ease. Default 'power2.out'.
138
146
  * @property {string} elementOpacityTransition - CSS value for LuminaElement opacity. Default '0.35s ease-out'.
@@ -166,6 +174,8 @@ export type ResolvedTransitionConfig = {
166
174
  revealDelayMs: number;
167
175
  revealDuration: number;
168
176
  revealEase: string;
177
+ defaultCascadeDelayMs: number;
178
+ defaultCascadeDuration: number;
169
179
  elementDuration: number;
170
180
  elementEase: string;
171
181
  elementOpacityTransition: string;
@@ -174,3 +174,21 @@ export function createElementController(
174
174
 
175
175
  return ctrl;
176
176
  }
177
+
178
+ /** No-op controller when engine has no store (e.g. studioEmbed mode). */
179
+ export function createNoopElementController(_engine: LuminaEngineLike, _id: string): IElementController {
180
+ const ctrl: IElementController = {
181
+ show: () => ctrl,
182
+ hide: () => ctrl,
183
+ toggle: () => ctrl,
184
+ opacity: () => ctrl,
185
+ transform: () => ctrl,
186
+ css: () => ctrl,
187
+ addClass: () => ctrl,
188
+ removeClass: () => ctrl,
189
+ animate: () => ctrl,
190
+ animateAsync: () => Promise.resolve(),
191
+ animateToProgress: () => ctrl
192
+ };
193
+ return ctrl;
194
+ }
@@ -122,7 +122,8 @@ const PATH_GENERATORS: Record<string, PathGenerator> = {
122
122
  features: (s) => {
123
123
  const arr = getValueAt(s, ['features']);
124
124
  const list = Array.isArray(arr) ? arr : [];
125
- const paths: ElementPath[] = [['header'], ['title']];
125
+ // Omit header (parent of title/description) to avoid animating container + children = jump
126
+ const paths: ElementPath[] = [['title']];
126
127
  if (getValueAt(s, ['description'])) paths.push(['description']);
127
128
  return [...paths, ...list.map((_: unknown, i: number) => ['features', i] as ElementPath)];
128
129
  },
@@ -142,7 +143,8 @@ const PATH_GENERATORS: Record<string, PathGenerator> = {
142
143
  steps: (s) => {
143
144
  const arr = getValueAt(s, ['steps']);
144
145
  const list = Array.isArray(arr) ? arr : [];
145
- const paths: ElementPath[] = [['header'], ['title']];
146
+ // Omit header (parent of title/subtitle) to avoid animating container + children = jump
147
+ const paths: ElementPath[] = [['title']];
146
148
  if (getValueAt(s, ['subtitle'])) paths.push(['subtitle']);
147
149
  return [...paths, ...list.map((_: unknown, i: number) => ['steps', i] as ElementPath)];
148
150
  },