lumina-slides 8.9.5 → 9.0.1

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 (119) hide show
  1. package/LUMINA_LLM_EXAMPLES.json +234 -0
  2. package/README.md +18 -18
  3. package/dist/lumina-slides.js +13153 -12619
  4. package/dist/lumina-slides.umd.cjs +217 -217
  5. package/dist/style.css +1 -1
  6. package/package.json +5 -4
  7. package/src/App.vue +16 -0
  8. package/src/animation/index.ts +11 -0
  9. package/src/animation/registry.ts +126 -0
  10. package/src/animation/stagger.ts +95 -0
  11. package/src/animation/types.ts +53 -0
  12. package/src/components/LandingPage.vue +229 -0
  13. package/src/components/LuminaDeck.vue +224 -0
  14. package/src/components/LuminaSpeakerNotes.vue +701 -0
  15. package/src/components/base/BaseSlide.vue +122 -0
  16. package/src/components/base/LuminaElement.vue +67 -0
  17. package/src/components/base/VideoPlayer.vue +204 -0
  18. package/src/components/layouts/LayoutAuto.vue +71 -0
  19. package/src/components/layouts/LayoutChart.vue +287 -0
  20. package/src/components/layouts/LayoutCustom.vue +92 -0
  21. package/src/components/layouts/LayoutDiagram.vue +253 -0
  22. package/src/components/layouts/LayoutFeatures.vue +121 -0
  23. package/src/components/layouts/LayoutFlex.vue +172 -0
  24. package/src/components/layouts/LayoutFree.vue +62 -0
  25. package/src/components/layouts/LayoutHalf.vue +127 -0
  26. package/src/components/layouts/LayoutStatement.vue +74 -0
  27. package/src/components/layouts/LayoutSteps.vue +106 -0
  28. package/src/components/layouts/LayoutTimeline.vue +104 -0
  29. package/src/components/layouts/LayoutVideo.vue +41 -0
  30. package/src/components/parts/FlexBullets.vue +45 -0
  31. package/src/components/parts/FlexButton.vue +132 -0
  32. package/src/components/parts/FlexImage.vue +54 -0
  33. package/src/components/parts/FlexOrdered.vue +44 -0
  34. package/src/components/parts/FlexSpacer.vue +13 -0
  35. package/src/components/parts/FlexStepper.vue +59 -0
  36. package/src/components/parts/FlexText.vue +29 -0
  37. package/src/components/parts/FlexTimeline.vue +67 -0
  38. package/src/components/parts/FlexTitle.vue +39 -0
  39. package/src/components/parts/LuminaBackground.vue +100 -0
  40. package/src/components/site/LivePreview.vue +101 -0
  41. package/src/components/site/SiteApi.vue +301 -0
  42. package/src/components/site/SiteDashboard.vue +604 -0
  43. package/src/components/site/SiteDocs.vue +3273 -0
  44. package/src/components/site/SiteExamples.vue +65 -0
  45. package/src/components/site/SiteFooter.vue +6 -0
  46. package/src/components/site/SiteHome.vue +362 -0
  47. package/src/components/site/SiteNavBar.vue +122 -0
  48. package/src/components/site/SitePlayground.vue +389 -0
  49. package/src/components/site/SitePromptBuilder.vue +266 -0
  50. package/src/components/site/SiteUserMenu.vue +90 -0
  51. package/src/components/studio/ActionEditor.vue +108 -0
  52. package/src/components/studio/ArrayEditor.vue +124 -0
  53. package/src/components/studio/CollapsibleSection.vue +33 -0
  54. package/src/components/studio/ColorField.vue +22 -0
  55. package/src/components/studio/EditorCanvas.vue +326 -0
  56. package/src/components/studio/EditorLayoutFeatures.vue +18 -0
  57. package/src/components/studio/EditorLayoutFixed.vue +46 -0
  58. package/src/components/studio/EditorLayoutFlex.vue +133 -0
  59. package/src/components/studio/EditorLayoutHalf.vue +18 -0
  60. package/src/components/studio/EditorLayoutStatement.vue +18 -0
  61. package/src/components/studio/EditorLayoutSteps.vue +18 -0
  62. package/src/components/studio/EditorLayoutTimeline.vue +18 -0
  63. package/src/components/studio/EditorNode.vue +89 -0
  64. package/src/components/studio/FieldEditor.vue +133 -0
  65. package/src/components/studio/IconPicker.vue +109 -0
  66. package/src/components/studio/LayerItem.vue +117 -0
  67. package/src/components/studio/LuminaStudio.vue +30 -0
  68. package/src/components/studio/SaveSuccessModal.vue +138 -0
  69. package/src/components/studio/SlideNavigator.vue +373 -0
  70. package/src/components/studio/SliderField.vue +44 -0
  71. package/src/components/studio/StudioInspector.vue +595 -0
  72. package/src/components/studio/StudioJsonEditor.vue +191 -0
  73. package/src/components/studio/StudioLayers.vue +145 -0
  74. package/src/components/studio/StudioSettings.vue +514 -0
  75. package/src/components/studio/StudioSidebar.vue +29 -0
  76. package/src/components/studio/StudioToolbar.vue +222 -0
  77. package/src/components/studio/fieldLabels.ts +224 -0
  78. package/src/components/studio/inspectors/DiagramEdgeEditor.vue +77 -0
  79. package/src/components/studio/inspectors/DiagramNodeEditor.vue +117 -0
  80. package/src/components/studio/nodes/StudioDiagramNode.vue +138 -0
  81. package/src/composables/useAuth.ts +87 -0
  82. package/src/composables/useEditor.ts +224 -0
  83. package/src/composables/useElementState.ts +81 -0
  84. package/src/composables/useFlexLayout.ts +122 -0
  85. package/src/composables/useKeyboard.ts +45 -0
  86. package/src/composables/useLumina.ts +32 -0
  87. package/src/composables/useStudio.ts +87 -0
  88. package/src/composables/useSwipeNav.ts +53 -0
  89. package/src/composables/useTransition.ts +373 -0
  90. package/src/core/Lumina.ts +819 -0
  91. package/src/core/animationConfig.ts +251 -0
  92. package/src/core/compression.ts +34 -0
  93. package/src/core/elementController.ts +170 -0
  94. package/src/core/elementId.ts +27 -0
  95. package/src/core/elementResolver.ts +207 -0
  96. package/src/core/events.ts +53 -0
  97. package/src/core/fonts.ts +100 -0
  98. package/src/core/presets.ts +231 -0
  99. package/src/core/prompts.ts +272 -0
  100. package/src/core/schema.ts +478 -0
  101. package/src/core/speaker-channel.ts +250 -0
  102. package/src/core/store.ts +465 -0
  103. package/src/core/theme.ts +666 -0
  104. package/src/core/types.ts +1615 -0
  105. package/src/directives/vStudio.ts +45 -0
  106. package/src/index.ts +175 -0
  107. package/src/main.ts +17 -0
  108. package/src/router/index.ts +92 -0
  109. package/src/style/main.css +462 -0
  110. package/src/utils/deep.ts +127 -0
  111. package/src/utils/firebase.ts +184 -0
  112. package/src/utils/streaming.ts +134 -0
  113. package/src/views/DashboardView.vue +32 -0
  114. package/src/views/DeckView.vue +289 -0
  115. package/src/views/HomeView.vue +17 -0
  116. package/src/views/SiteLayout.vue +21 -0
  117. package/src/views/StudioView.vue +61 -0
  118. package/src/vite-env.d.ts +6 -0
  119. package/IMPLEMENTATION.md +0 -418
@@ -0,0 +1,819 @@
1
+ import { createApp, App as VueApp, watch } from 'vue';
2
+ import LuminaDeck from '../components/LuminaDeck.vue';
3
+ import LuminaStudio from '../components/studio/LuminaStudio.vue';
4
+ import LuminaSpeakerNotes from '../components/LuminaSpeakerNotes.vue';
5
+ import LayoutStatement from '../components/layouts/LayoutStatement.vue';
6
+ import LayoutHalf from '../components/layouts/LayoutHalf.vue';
7
+ import LayoutFeatures from '../components/layouts/LayoutFeatures.vue';
8
+ import LayoutTimeline from '../components/layouts/LayoutTimeline.vue';
9
+ import LayoutSteps from '../components/layouts/LayoutSteps.vue';
10
+ import LayoutFlex from '../components/layouts/LayoutFlex.vue';
11
+ import LayoutAuto from '../components/layouts/LayoutAuto.vue';
12
+ import LayoutCustom from '../components/layouts/LayoutCustom.vue';
13
+ import LayoutChart from '../components/layouts/LayoutChart.vue';
14
+ import LayoutVideo from '../components/layouts/LayoutVideo.vue';
15
+ import LayoutDiagram from '../components/layouts/LayoutDiagram.vue';
16
+ import LayoutFree from '../components/layouts/LayoutFree.vue';
17
+ import LuminaElement from '../components/base/LuminaElement.vue';
18
+ import { createStore, StoreKey, LuminaStore } from './store';
19
+ import { resolveTransitionConfig } from './animationConfig';
20
+ import { computeStagger, resolveAnimationFromInput } from '../animation';
21
+ import { createElementController } from './elementController';
22
+ import { parsePath, resolveId, getElementIds } from './elementResolver';
23
+ import { elemId } from './elementId';
24
+ import type { ElementPath } from './elementResolver';
25
+ import { bus } from './events';
26
+ import { ThemeManager, deepMerge } from './theme';
27
+ import { SpeakerChannel } from './speaker-channel';
28
+ import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions, TimelineTracks, ThemeConfig } from './types';
29
+ import '../style/main.css';
30
+
31
+ /**
32
+ * Lumina Engine — presentation renderer from declarative JSON. Main entry point for the library.
33
+ *
34
+ * @description
35
+ * Mount a slide deck into a DOM element, load/patch deck data, subscribe to events (slideChange, action),
36
+ * 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`.
39
+ * `engine.element(id).animate()` and `getElementById` require the element's slide to be mounted (no-op/null otherwise).
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { Lumina } from "lumina-slides";
44
+ * import "lumina-slides/style.css";
45
+ *
46
+ * 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: [
50
+ * { type: "statement", title: "Hello", subtitle: "World" }
51
+ * ]});
52
+ * engine.on("action", (p) => console.log("clicked", p));
53
+ * ```
54
+ *
55
+ * @see Deck
56
+ * @see LuminaOptions
57
+ * @see ElementController
58
+ * @see resolveTransitionConfig
59
+ * @see IMPLEMENTATION.md
60
+ * @see AGENTS.md
61
+ */
62
+ export class Lumina {
63
+ private app: VueApp;
64
+ private store: LuminaStore;
65
+
66
+ /** Key-value store: `engine.data.get/set/has/delete/clear/keys`. Persists across deck loads. */
67
+ public readonly data: LuminaDataStore;
68
+
69
+ // Speaker Notes state
70
+ private speakerWindow: Window | null = null;
71
+ private speakerChannel: SpeakerChannel | null = null;
72
+ private speakerChannelId: string;
73
+ private speakerUnsubscribe: (() => void) | null = null;
74
+
75
+ // Timeline (Remotion-style) state
76
+ private _timelineProgress = 0;
77
+ private _timelineCancel: (() => void) | null = null;
78
+
79
+ /**
80
+ * Creates a new Lumina instance and mounts the presentation UI.
81
+ *
82
+ * @param selector - CSS selector for the mount target (e.g. `"#app"`, `".deck"`).
83
+ * @param options - Optional: theme, loop, navigation, touch, studio, etc. See {@link LuminaOptions}.
84
+ */
85
+ constructor(public selector: string, options: LuminaOptions = {}) {
86
+ // Generate unique channel ID for this instance
87
+ this.speakerChannelId = `lumina-speaker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
88
+
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 currentSlide = this.store.state.deck?.slides[newIndex];
118
+ if (currentSlide) {
119
+ bus.emit('slideChange', {
120
+ index: newIndex,
121
+ previousIndex: oldIndex ?? 0,
122
+ slide: currentSlide
123
+ });
124
+ }
125
+ });
126
+
127
+ // Watch for metadata changes (Theme, Orb, etc.)
128
+ watch(() => this.store.state.deck?.meta, (newMeta) => {
129
+ if (newMeta) {
130
+ // 1. Construct Theme Overrides from Meta
131
+ // - meta.themeConfig: full ThemeConfig object (e.g. from theme-custom-example.json)
132
+ // - meta.colors, meta.typography, etc.: direct overrides from Studio (take precedence)
133
+ const fromThemeConfig = (newMeta as { themeConfig?: object }).themeConfig || {};
134
+ const direct = {
135
+ colors: newMeta.colors,
136
+ typography: newMeta.typography,
137
+ spacing: newMeta.spacing,
138
+ borderRadius: newMeta.borderRadius,
139
+ effects: newMeta.effects,
140
+ components: newMeta.components
141
+ };
142
+ const overrides = deepMerge(fromThemeConfig as Record<string, unknown>, direct as Record<string, unknown>) as ThemeConfig;
143
+
144
+ // Resolve theme: deck.theme (top-level, e.g. from theme-ocean.json) takes precedence over
145
+ // meta.theme, then constructor options. Ensures examples and loaded decks keep their theme.
146
+ const deck = this.store.state.deck;
147
+ const themeOrName = deck?.theme ?? newMeta.theme ?? this.store.state.options?.theme ?? 'default';
148
+
149
+ // 2. Dynamic Theme Application
150
+ // This ensures that any change in Studio (which updates deck.meta) is immediately reflected
151
+ // via CSS variables injected by ThemeManager.
152
+ ThemeManager.inject(themeOrName, overrides);
153
+
154
+ // 3. Fallback/Direct variable handling if needed (e.g. for specific legacy props)
155
+ if (newMeta.orbColor) {
156
+ // Update the primary color variable as a fallback or if specifically mapped
157
+ document.documentElement.style.setProperty('--lumina-colors-primary', newMeta.orbColor);
158
+ }
159
+
160
+ bus.emit('themeChange', { theme: typeof themeOrName === 'string' ? themeOrName : 'custom' });
161
+ }
162
+ }, { deep: true });
163
+
164
+ // Register Core Components
165
+ this.app.component('layout-statement', LayoutStatement);
166
+ this.app.component('layout-half', LayoutHalf);
167
+ this.app.component('layout-features', LayoutFeatures);
168
+ this.app.component('layout-timeline', LayoutTimeline);
169
+ this.app.component('layout-steps', LayoutSteps);
170
+ this.app.component('layout-flex', LayoutFlex);
171
+ this.app.component('layout-auto', LayoutAuto);
172
+ this.app.component('layout-custom', LayoutCustom);
173
+ this.app.component('layout-chart', LayoutChart);
174
+ this.app.component('layout-video', LayoutVideo);
175
+ this.app.component('layout-diagram', LayoutDiagram);
176
+ this.app.component('layout-free', LayoutFree);
177
+ this.app.component('LuminaElement', LuminaElement);
178
+
179
+ // Internal Event Listeners
180
+ bus.on('action', (payload) => {
181
+ // Feature: Real-time Slide Updates (e.g. from Diagram Layout)
182
+ if (payload.type === 'slide-update' && typeof payload.slideIndex === 'number') {
183
+ const { slideIndex, patch } = payload;
184
+ if (patch && typeof patch === 'object') {
185
+ if ('nodes' in patch) {
186
+ this.store.updateNode(`slides.${slideIndex}.nodes`, patch.nodes);
187
+ }
188
+ if ('edges' in patch) {
189
+ this.store.updateNode(`slides.${slideIndex}.edges`, patch.edges);
190
+ }
191
+ }
192
+ }
193
+ this.store.recordAction(payload);
194
+ });
195
+
196
+ this.app.mount(this.selector);
197
+
198
+ this.syncSpeakerNotes();
199
+ }
200
+
201
+ /**
202
+ * Loads a full deck, replacing any existing content.
203
+ * Use this for initial load or when swapping to a different deck.
204
+ * Emits `error` if the deck is invalid; `ready` on success.
205
+ *
206
+ * @param deckData - Full deck: `{ meta: { title }, slides: [...] }`. See {@link Deck}.
207
+ * @example
208
+ * engine.load({ meta: { title: "Q4 Review" }, slides: [ { type: "statement", title: "Intro" } ] });
209
+ */
210
+ public load(deckData: Deck) {
211
+ if (!deckData || !Array.isArray((deckData as any)?.slides)) {
212
+ bus.emit('error', new Error('Invalid deck: expected { meta?, slides: [] }'));
213
+ return;
214
+ }
215
+ try {
216
+ this.store.loadDeck(deckData);
217
+ bus.emit('ready', deckData);
218
+ this.syncSpeakerNotes();
219
+ } catch (e) {
220
+ bus.emit('error', e instanceof Error ? e : new Error(String(e)));
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Merges partial changes into the current deck without full reload.
226
+ * Use for incremental updates (e.g. from streaming or edits).
227
+ * Emits `patch` with the diff.
228
+ *
229
+ * @param diff - Partial deck: `meta` and/or `slides`. Deep-merged; `slides` array is replaced if provided.
230
+ * @example
231
+ * engine.patch({ meta: { title: "Updated Title" } });
232
+ */
233
+ public patch(diff: any) {
234
+ this.store.patchDeck(diff);
235
+ bus.emit('patch', { diff: diff ?? {} });
236
+ this.syncSpeakerNotes();
237
+ }
238
+
239
+ /**
240
+ * Exports current slide index, type, and interaction history for LLM context.
241
+ * Use when feeding "where the user is" back into an agent.
242
+ *
243
+ * @returns Frozen object: `{ status, currentSlide: { index, id, type, title }, narrative, engagementLevel, history }`.
244
+ * @example
245
+ * const state = engine.exportState();
246
+ * // "User is on slide 3 (features). Session: clicked 'Learn More' -> navigated next."
247
+ */
248
+ public exportState() {
249
+ const { currentIndex, deck, actionHistory } = this.store.state;
250
+ const slide = deck?.slides[currentIndex];
251
+ const context = actionHistory.map(a => `User ${a.type}ed on ${a.label || a.value}`).join(' -> ');
252
+ const interest = actionHistory.length > 5 ? "High Engagement" : "Low Engagement";
253
+
254
+ return Object.freeze({
255
+ status: "active",
256
+ currentSlide: {
257
+ index: currentIndex,
258
+ id: slide?.id || currentIndex,
259
+ type: slide?.type,
260
+ title: (slide && 'title' in slide ? (slide as any).title : null) || "(No Title)"
261
+ },
262
+ narrative: `User is currently on slide ${currentIndex + 1}. Session Flow: ${context || "Just started"}.`,
263
+ engagementLevel: interest,
264
+ history: [...actionHistory]
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Subscribes to an engine event. Use for slide changes, button clicks, errors, and lifecycle.
270
+ *
271
+ * @param event - Event name. See {@link LuminaEventMap}: `ready`, `slideChange`, `action`, `error`,
272
+ * `patch`, `destroy`, `navigate`, `themeChange`, `speakerNotesOpen`, `speakerNotesClose`,
273
+ * `timelineSeek`, `timelineComplete`, `revealStart`, `revealComplete`, `revealElement`.
274
+ * @param handler - Callback with typed payload for the event.
275
+ * @example
276
+ * engine.on("slideChange", ({ index, slide }) => console.log(`Slide ${index}: ${slide.type}`));
277
+ * engine.on("action", (p) => { if (p.type === "button") sendToAgent(`Clicked: ${p.label}`); });
278
+ * engine.on("patch", ({ diff }) => console.log("Deck patched", diff));
279
+ */
280
+ public on<K extends keyof LuminaEventMap>(event: K, handler: (e: LuminaEventMap[K]) => void) {
281
+ bus.on(event, handler);
282
+ }
283
+
284
+ /**
285
+ * Unsubscribes a previously added handler for an event.
286
+ *
287
+ * @param event - Event name.
288
+ * @param handler - The same function reference passed to `on`.
289
+ */
290
+ public off<K extends keyof LuminaEventMap>(event: K, handler: (e: LuminaEventMap[K]) => void) {
291
+ bus.off(event, handler);
292
+ }
293
+
294
+ /**
295
+ * Emits an error to subscribers of the `error` event. Use to forward errors from your app (e.g. load failures, API errors).
296
+ *
297
+ * @param err - Error instance to emit.
298
+ */
299
+ public emitError(err: Error) {
300
+ bus.emit('error', err);
301
+ }
302
+
303
+ /**
304
+ * Unmounts the app, clears events, and closes speaker notes. Call when removing the presentation from the page.
305
+ * Emits `destroy` before clearing handlers.
306
+ */
307
+ public destroy() {
308
+ bus.emit('destroy', {});
309
+ this.closeSpeakerNotes();
310
+ if (this.app) {
311
+ this.app.unmount();
312
+ }
313
+ bus.clear();
314
+ }
315
+
316
+ /** Zero-based index of the currently visible slide. */
317
+ public get currentSlideIndex() {
318
+ return this.store.state.currentIndex;
319
+ }
320
+
321
+ /**
322
+ * Jumps to a slide by index.
323
+ * @param index - Zero-based slide index. Clamped to [0, slides.length - 1].
324
+ */
325
+ public goTo(index: number) {
326
+ const from = this.store.state.currentIndex;
327
+ this.store.goto(index);
328
+ if (this.store.state.currentIndex !== from) {
329
+ bus.emit('navigate', { direction: 'goto', fromIndex: from, toIndex: this.store.state.currentIndex });
330
+ }
331
+ this.syncSpeakerNotes();
332
+ }
333
+
334
+ /** Advances to the next slide (or first if loop and at end). */
335
+ public next() {
336
+ const from = this.store.state.currentIndex;
337
+ this.store.next();
338
+ if (this.store.state.currentIndex !== from) {
339
+ bus.emit('navigate', { direction: 'next', fromIndex: from, toIndex: this.store.state.currentIndex });
340
+ }
341
+ this.syncSpeakerNotes();
342
+ }
343
+
344
+ /** Goes to the previous slide (or last if loop and at start). */
345
+ public prev() {
346
+ const from = this.store.state.currentIndex;
347
+ this.store.prev();
348
+ if (this.store.state.currentIndex !== from) {
349
+ bus.emit('navigate', { direction: 'prev', fromIndex: from, toIndex: this.store.state.currentIndex });
350
+ }
351
+ this.syncSpeakerNotes();
352
+ }
353
+
354
+ /**
355
+ * Returns the DOM node with data-lumina-id equal to `id`. Used by ElementController.animate
356
+ * and for direct DOM access. The element exists only when its slide is mounted (e.g. switching
357
+ * slides unmounts the previous slide’s content).
358
+ *
359
+ * @param id - Element id (from resolveId, elemId, or explicit id in the slide JSON).
360
+ * @returns The HTMLElement or null if not found or not yet mounted.
361
+ *
362
+ * @see element
363
+ * @see resolveId
364
+ * @see elemId
365
+ */
366
+ public getElementById(id: string): HTMLElement | null {
367
+ const container = document.querySelector(this.selector);
368
+ if (!container) return null;
369
+ const root = container.querySelector('[data-lumina-root]') || container;
370
+ const escaped = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
371
+ return (root.querySelector(`[data-lumina-id="${escaped}"]`) as HTMLElement) || null;
372
+ }
373
+
374
+ /**
375
+ * Returns an {@link ElementController} to change one slide element’s state in real time
376
+ * (visibility, opacity, transform, classes, style, GSAP animation). Use for: starting with
377
+ * a blank slide and revealing items in order (via meta.initialElementState + .show()), hiding
378
+ * highlights, or syncing with voice/agent.
379
+ *
380
+ * Overloads:
381
+ * - **element(id: string)** — by resolved or explicit id (e.g. "s0-title", "intro-tag", or a
382
+ * diagram node id). Use when you already have the id (from elements(), getElementIds, or
383
+ * meta.initialElementState).
384
+ * - **element(slideIndex: number, path: string | ElementPath)** — by logical path. path can be
385
+ * a string ("tag", "features.0", "elements.0.elements.1") or an array (['elements', 0, 'elements', 1]).
386
+ * The id is resolved via resolveId; then the same controller is returned as element(id).
387
+ *
388
+ * The target element must be wrapped with LuminaElement or have data-lumina-id (layouts do this
389
+ * via resolveId). If the element is not in the DOM (e.g. another slide), show/hide/opacity/etc.
390
+ * still update the store and apply when the slide is shown; animate() is a no-op until mounted.
391
+ *
392
+ * @param idOrSlide - Element id string, or (when using path) zero-based slide index.
393
+ * @param path - When idOrSlide is a number: path string or ElementPath. Ignored when idOrSlide is string.
394
+ * @returns ElementController (chainable: .show(), .hide(), .opacity(n), .animate({...}), etc.).
395
+ *
396
+ * @example
397
+ * engine.element('s0-title').hide();
398
+ * engine.element(0, 'tag').show();
399
+ * engine.element(1, 'features.0').opacity(0.5).animate({ to: { opacity: 1 }, duration: 0.6 });
400
+ * engine.element(2, ['elements', 0, 'elements', 1]).show();
401
+ * // With meta.initialElementState hiding s0-tag, s0-title, s0-subtitle:
402
+ * engine.element(0, 'tag').show();
403
+ * await delay(400);
404
+ * engine.element(0, 'title').show().animate({ from: { y: 20, opacity: 0 }, to: { y: 0, opacity: 1 }, duration: 0.5 });
405
+ *
406
+ * @see elements
407
+ * @see getElementById
408
+ * @see resolveId
409
+ * @see getElementIds
410
+ * @see ElementController
411
+ * @see DeckMeta.initialElementState
412
+ */
413
+ public element(id: string): ElementController;
414
+ public element(slideIndex: number, path: string | ElementPath): ElementController;
415
+ public element(idOrSlide: string | number, path?: string | ElementPath): ElementController {
416
+ if (typeof idOrSlide === 'number') {
417
+ const slide = this.store.state.deck?.slides[idOrSlide];
418
+ const p = parsePath(path!);
419
+ const id = slide ? resolveId(slide, idOrSlide, p) : elemId(idOrSlide, ...p);
420
+ return createElementController(this.store, this, id);
421
+ }
422
+ return createElementController(this.store, this, idOrSlide);
423
+ }
424
+
425
+ /**
426
+ * Returns all element ids for a slide. Use to discover which ids exist (e.g. for
427
+ * meta.initialElementState, or to iterate and control elements). Each id can be passed to
428
+ * engine.element(id). Uses the same path→id logic as element(slideIndex, path); ids follow
429
+ * resolveId (explicit id, slide.ids, or elemId(slideIndex, ...path)).
430
+ *
431
+ * @param slideIndex - Zero-based slide index. If out of range or deck not loaded, returns [].
432
+ * @returns Array of id strings (e.g. ["s0-slide","s0-tag","s0-title","s0-subtitle"] for a statement slide).
433
+ *
434
+ * @example
435
+ * const ids = engine.elements(0);
436
+ * ids.forEach(id => { if (id.endsWith('-title')) engine.element(id).hide(); });
437
+ *
438
+ * @see element
439
+ * @see getElementIds
440
+ * @see resolveId
441
+ * @see DeckMeta.initialElementState
442
+ */
443
+ public elements(slideIndex: number): string[] {
444
+ const slide = this.store.state.deck?.slides[slideIndex];
445
+ if (!slide) return [];
446
+ return getElementIds(slide, slideIndex);
447
+ }
448
+
449
+ /**
450
+ * Hides all controlled elements of a slide. Use for blank-slide or reset-before-reveal flows.
451
+ *
452
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
453
+ * @returns this (chainable).
454
+ *
455
+ * @example
456
+ * engine.hideAll(); // current slide
457
+ * engine.hideAll(2); await engine.delay(300); engine.revealInSequence(2);
458
+ */
459
+ public hideAll(slideIndex?: number): this {
460
+ const i = slideIndex ?? this.currentSlideIndex;
461
+ this.elements(i).forEach((id) => this.element(id).hide());
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Shows all controlled elements of a slide.
467
+ *
468
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
469
+ * @returns this (chainable).
470
+ */
471
+ public showAll(slideIndex?: number): this {
472
+ const i = slideIndex ?? this.currentSlideIndex;
473
+ this.elements(i).forEach((id) => this.element(id).show());
474
+ return this;
475
+ }
476
+
477
+ /**
478
+ * Hides all elements of the slide, then shows only the given ids. Use for focus mode or
479
+ * spotlight effects.
480
+ *
481
+ * @param ids - Element ids to keep visible.
482
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
483
+ * @returns this (chainable).
484
+ *
485
+ * @example
486
+ * engine.showOnly(['s1-title', 's1-tag'], 1);
487
+ */
488
+ public showOnly(ids: string[], slideIndex?: number): this {
489
+ const i = slideIndex ?? this.currentSlideIndex;
490
+ this.hideAll(i);
491
+ ids.forEach((id) => this.element(id).show());
492
+ return this;
493
+ }
494
+
495
+ /**
496
+ * Hides all elements except the given ids (convenience for hideAll + show on exceptIds).
497
+ *
498
+ * @param exceptIds - Element ids to keep visible.
499
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
500
+ * @returns this (chainable).
501
+ */
502
+ public hideAllExcept(exceptIds: string[], slideIndex?: number): this {
503
+ return this.showOnly(exceptIds, slideIndex);
504
+ }
505
+
506
+ /**
507
+ * Resets element state for a slide to the initial values (as when entering the slide).
508
+ * Respects meta.initialElementState and meta.elementControl.defaultVisible. Use before
509
+ * running a reveal sequence again.
510
+ *
511
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
512
+ * @returns this (chainable).
513
+ */
514
+ public resetSlide(slideIndex?: number): this {
515
+ this.store.resetElementStateForSlide(slideIndex ?? this.currentSlideIndex);
516
+ return this;
517
+ }
518
+
519
+ /**
520
+ * Returns a Promise that resolves after the given milliseconds. Useful for chaining
521
+ * with async/await between reveal steps or after hideAll.
522
+ *
523
+ * @param ms - Delay in milliseconds.
524
+ *
525
+ * @example
526
+ * engine.hideAll(0); await engine.delay(500); engine.revealInSequence(0);
527
+ */
528
+ public delay(ms: number): Promise<void> {
529
+ return new Promise((resolve) => setTimeout(resolve, ms));
530
+ }
531
+
532
+ /**
533
+ * Reveals slide elements one by one with optional stagger and animation. Optionally
534
+ * hides all first (hideFirst: true). Resolves when the sequence has been scheduled
535
+ * (does not wait for the last animation to finish).
536
+ *
537
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide.
538
+ * @param options - Stagger delay, animation from/to, and filters (only, exclude).
539
+ * @returns Promise that resolves after the last element's scheduled reveal time.
540
+ *
541
+ * @example
542
+ * engine.on('slideChange', (e) => { engine.revealInSequence(e.index, { delayMs: 500 }); });
543
+ * await engine.revealInSequence(0, { only: ['s0-tag', 's0-title'], hideFirst: true });
544
+ */
545
+ public revealInSequence(slideIndex?: number, options?: RevealInSequenceOptions): Promise<void> {
546
+ const i = slideIndex ?? this.currentSlideIndex;
547
+ let ids = this.elements(i);
548
+ const opts = options ?? {};
549
+ if (opts.only?.length) ids = ids.filter((id) => opts.only!.includes(id));
550
+ if (opts.exclude?.length) ids = ids.filter((id) => !opts.exclude!.includes(id));
551
+
552
+ const slide = this.store.state.deck?.slides[i];
553
+ const cfg = resolveTransitionConfig(this.store, slide ?? undefined);
554
+ const delayMs = opts.delayMs ?? cfg.revealDelayMs;
555
+ const animate = opts.animate !== false;
556
+
557
+ const resolved = resolveAnimationFromInput(
558
+ {
559
+ preset: opts.preset,
560
+ motion: opts.motion,
561
+ from: opts.from,
562
+ to: opts.to,
563
+ ease: opts.ease,
564
+ duration: opts.duration,
565
+ },
566
+ { from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration }
567
+ );
568
+
569
+ const steps = computeStagger({
570
+ ids,
571
+ delayMs,
572
+ mode: opts.staggerMode ?? 'sequential',
573
+ randomSeed: opts.randomSeed ?? i,
574
+ });
575
+
576
+ if (opts.hideFirst !== false) this.hideAll(i);
577
+
578
+ return new Promise((resolve) => {
579
+ bus.emit('revealStart', { slideIndex: i, elementIds: steps.map((s) => s.id) });
580
+ if (steps.length === 0) {
581
+ bus.emit('revealComplete', { slideIndex: i });
582
+ resolve();
583
+ return;
584
+ }
585
+ steps.forEach((step, idx) => {
586
+ setTimeout(() => {
587
+ bus.emit('revealElement', { slideIndex: i, elementId: step.id, index: idx });
588
+ this.element(step.id).show();
589
+ if (animate) {
590
+ this.element(step.id).animate({
591
+ from: resolved.from,
592
+ to: resolved.to,
593
+ duration: resolved.duration,
594
+ ease: resolved.ease,
595
+ });
596
+ }
597
+ }, step.delayMs);
598
+ });
599
+ const mode = opts.staggerMode ?? 'sequential';
600
+ const totalDelay =
601
+ mode === 'sequential' ? steps.length * delayMs : (steps.length ? Math.max(...steps.map((s) => s.delayMs)) : 0);
602
+ setTimeout(() => {
603
+ bus.emit('revealComplete', { slideIndex: i });
604
+ resolve();
605
+ }, totalDelay);
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Current timeline progress 0–1. Set by {@link seekTo} and {@link playTimeline}.
611
+ */
612
+ public get timelineProgress(): number {
613
+ return this._timelineProgress;
614
+ }
615
+
616
+ /**
617
+ * Seek the slide's timeline to a progress 0–1. Applies keyframes from `slide.timelineTracks` to each
618
+ * element via `element(id).animateToProgress(progress, keyframes)`. No-op if the slide has no
619
+ * `timelineTracks`. Remotion-style: scrub the composition.
620
+ *
621
+ * @param progress - 0–1. Interpolation between keyframes is linear.
622
+ * @param slideIndex - Omit to use the current slide.
623
+ *
624
+ * @example
625
+ * engine.seekTo(0); // reset to first keyframes
626
+ * engine.seekTo(0.5); // halfway
627
+ * slider.oninput = () => engine.seekTo(parseFloat(slider.value));
628
+ */
629
+ public seekTo(progress: number, slideIndex?: number): this {
630
+ const i = slideIndex ?? this.currentSlideIndex;
631
+ const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
632
+ const timeline = slide?.timelineTracks;
633
+ this._timelineProgress = Math.max(0, Math.min(1, progress));
634
+ bus.emit('timelineSeek', { progress: this._timelineProgress, slideIndex: i });
635
+ if (!timeline || typeof timeline !== 'object') return this;
636
+ for (const id of Object.keys(timeline)) {
637
+ this.element(id).animateToProgress(this._timelineProgress, timeline[id]);
638
+ }
639
+ return this;
640
+ }
641
+
642
+ /**
643
+ * Play the current slide's timeline from 0 to 1 over the given duration. Uses requestAnimationFrame.
644
+ * Resolves when done. Call the returned cancel to stop early. If the slide has no `timelineTracks`, resolves immediately.
645
+ *
646
+ * @param durationSeconds - Time to go from 0 to 1. Default 5.
647
+ * @param slideIndex - Omit to use the current slide.
648
+ * @returns Promise that resolves when finished, and a cancel function.
649
+ *
650
+ * @example
651
+ * const [promise, cancel] = engine.playTimeline(6);
652
+ * promise.then(() => console.log('done'));
653
+ * // cancel() to stop
654
+ */
655
+ public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
656
+ const i = slideIndex ?? this.currentSlideIndex;
657
+ const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
658
+ if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
659
+ return [Promise.resolve(), () => {}];
660
+ }
661
+ if (this._timelineCancel) this._timelineCancel();
662
+ let rafId = 0;
663
+ let start = 0;
664
+ const cancel = () => {
665
+ if (rafId) cancelAnimationFrame(rafId);
666
+ rafId = 0;
667
+ this._timelineCancel = null;
668
+ };
669
+ this._timelineCancel = cancel;
670
+ const run = (t: number) => {
671
+ if (!start) start = t;
672
+ const elapsed = (t - start) / 1000;
673
+ const p = Math.min(1, elapsed / durationSeconds);
674
+ this.seekTo(p, i);
675
+ if (p < 1) rafId = requestAnimationFrame(run);
676
+ else {
677
+ rafId = 0;
678
+ this._timelineCancel = null;
679
+ bus.emit('timelineComplete', { slideIndex: i });
680
+ resolvePlay();
681
+ }
682
+ };
683
+ let resolvePlay: () => void;
684
+ const promise = new Promise<void>((r) => { resolvePlay = r; });
685
+ rafId = requestAnimationFrame(run);
686
+ return [promise, cancel];
687
+ }
688
+
689
+ public openSpeakerNotes(): Window | null {
690
+ if (this.speakerWindow && !this.speakerWindow.closed) {
691
+ this.speakerWindow.focus();
692
+ return this.speakerWindow;
693
+ }
694
+
695
+ if (!SpeakerChannel.isSupported()) {
696
+ console.error('[Lumina] BroadcastChannel not supported.');
697
+ return null;
698
+ }
699
+
700
+ const width = 600;
701
+ const height = 800;
702
+ const left = window.screenX + window.outerWidth;
703
+ const top = window.screenY;
704
+
705
+ this.speakerWindow = window.open(
706
+ 'about:blank',
707
+ `lumina-speaker-notes-${this.speakerChannelId}`,
708
+ `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
709
+ );
710
+
711
+ if (!this.speakerWindow) {
712
+ console.warn('[Lumina] Popup blocked.');
713
+ return null;
714
+ }
715
+
716
+ const win = this.speakerWindow;
717
+ win.document.open();
718
+ win.document.write(`
719
+ <!DOCTYPE html>
720
+ <html lang="en">
721
+ <head>
722
+ <meta charset="UTF-8">
723
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
724
+ <title>Speaker Notes - Lumina</title>
725
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
726
+ <style>body{margin:0;padding:0;}#speaker-notes-root{min-height:100vh;min-height:100dvh;}</style>
727
+ </head>
728
+ <body>
729
+ <div id="speaker-notes-root"></div>
730
+ </body>
731
+ </html>
732
+ `);
733
+ win.document.close();
734
+
735
+ setTimeout(() => {
736
+ if (win.closed) return;
737
+ Array.from(document.querySelectorAll('style, link[rel="stylesheet"]')).forEach(node => {
738
+ win.document.head.appendChild(node.cloneNode(true));
739
+ });
740
+
741
+ const container = win.document.getElementById('speaker-notes-root');
742
+ if (container) {
743
+ const notesApp = createApp(LuminaSpeakerNotes, { channelId: this.speakerChannelId });
744
+ notesApp.mount(container);
745
+ win.addEventListener('unload', () => notesApp.unmount());
746
+ }
747
+ }, 0);
748
+
749
+ this.initSpeakerChannel();
750
+ bus.emit('speakerNotesOpen', {});
751
+ return this.speakerWindow;
752
+ }
753
+
754
+ public closeSpeakerNotes(): void {
755
+ bus.emit('speakerNotesClose', {});
756
+ if (this.speakerUnsubscribe) {
757
+ this.speakerUnsubscribe();
758
+ this.speakerUnsubscribe = null;
759
+ }
760
+ if (this.speakerChannel) {
761
+ this.speakerChannel.notifyClose();
762
+ this.speakerChannel.destroy();
763
+ this.speakerChannel = null;
764
+ }
765
+ if (this.speakerWindow && !this.speakerWindow.closed) {
766
+ this.speakerWindow.close();
767
+ }
768
+ this.speakerWindow = null;
769
+ }
770
+
771
+ public get isSpeakerNotesOpen(): boolean {
772
+ return this.speakerWindow !== null && !this.speakerWindow.closed;
773
+ }
774
+
775
+ private initSpeakerChannel(): void {
776
+ this.speakerChannel = SpeakerChannel.getInstance(this.speakerChannelId);
777
+ this.speakerUnsubscribe = this.speakerChannel.subscribe((msg: SpeakerSyncPayload) => {
778
+ switch (msg.action) {
779
+ case 'next': this.next(); break;
780
+ case 'prev': this.prev(); break;
781
+ case 'goto': if (msg.index !== undefined) this.goTo(msg.index); break;
782
+ case 'ping': this.syncSpeakerNotes(); break;
783
+ }
784
+ });
785
+ this.syncSpeakerNotes();
786
+ const checkWindow = setInterval(() => {
787
+ if (this.speakerWindow?.closed) {
788
+ clearInterval(checkWindow);
789
+ this.closeSpeakerNotes();
790
+ }
791
+ }, 1000);
792
+ }
793
+
794
+ private syncSpeakerNotes(): void {
795
+ if (!this.speakerChannel) return;
796
+ const { currentIndex, deck } = this.store.state;
797
+ if (!deck) return;
798
+
799
+ const currentSlide = deck.slides[currentIndex];
800
+ const nextSlide = deck.slides[currentIndex + 1];
801
+
802
+ const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined => {
803
+ if (!slide) return undefined;
804
+ if ('title' in slide && typeof (slide as any).title === 'string') return (slide as any).title;
805
+ return undefined;
806
+ };
807
+
808
+ this.speakerChannel.send({
809
+ action: 'state',
810
+ index: currentIndex,
811
+ totalSlides: deck.slides.length,
812
+ currentNotes: currentSlide?.notes,
813
+ nextSlidePreview: nextSlide ? {
814
+ title: getSlideTitle(nextSlide),
815
+ type: nextSlide.type
816
+ } : undefined
817
+ });
818
+ }
819
+ }