lumina-slides 9.0.1 → 9.0.3

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.
@@ -36,6 +36,7 @@ const router = useRouter();
36
36
  const decks = [
37
37
  { id: 'deck', title: 'Feature Showcase', description: 'A comprehensive tour of all major features.', icon: '🚀' },
38
38
  { id: 'layout-element-control', title: 'Element Control', description: 'Reveal-on-demand: elements start hidden and appear automatically.', icon: '🎭' },
39
+ { id: 'layout-template-tags', title: 'Template Tags', description: '{{key}} placeholders in slide content, resolved from engine.data. Updates reactively.', icon: '🏷️' },
39
40
  { id: 'animation-presets', title: 'Animation: Presets', description: 'Built-in presets: fadeUp, scaleIn, spring. Use with animate() or revealInSequence.', icon: '✨' },
40
41
  { id: 'animation-stagger', title: 'Animation: Stagger', description: 'Stagger modes: center-out, wave, random. Control order and timing.', icon: '〰️' },
41
42
  { id: 'animation-timeline', title: 'Animation: Timeline', description: 'Remotion-style: scrub 0–1, keyframes per element, seekTo and playTimeline.', icon: '⏯️' },
@@ -25,7 +25,7 @@ import type { ElementPath } from './elementResolver';
25
25
  import { bus } from './events';
26
26
  import { ThemeManager, deepMerge } from './theme';
27
27
  import { SpeakerChannel } from './speaker-channel';
28
- import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions, TimelineTracks, ThemeConfig } from './types';
28
+ import type { Deck, LuminaOptions, LuminaEventMap, LuminaDataStore, SpeakerSyncPayload, BaseSlideData, ElementController, RevealInSequenceOptions, ThemeConfig } from './types';
29
29
  import '../style/main.css';
30
30
 
31
31
  /**
@@ -114,13 +114,18 @@ export class Lumina {
114
114
  // Watch for state changes and emit public events
115
115
  watch(() => this.store.state.currentIndex, (newIndex, oldIndex) => {
116
116
  this.store.resetElementStateForSlide(newIndex);
117
- const currentSlide = this.store.state.deck?.slides[newIndex];
117
+ const deck = this.store.state.deck;
118
+ const currentSlide = deck?.slides?.[newIndex];
118
119
  if (currentSlide) {
119
120
  bus.emit('slideChange', {
120
121
  index: newIndex,
121
122
  previousIndex: oldIndex ?? 0,
122
123
  slide: currentSlide
123
124
  });
125
+ const rev = deck?.meta?.reveal ?? currentSlide?.reveal;
126
+ if (rev && typeof rev === 'object') {
127
+ this.revealInSequence(newIndex, this.getRevealOptionsFromDeck(deck!, newIndex));
128
+ }
124
129
  }
125
130
  });
126
131
 
@@ -130,8 +135,8 @@ export class Lumina {
130
135
  // 1. Construct Theme Overrides from Meta
131
136
  // - meta.themeConfig: full ThemeConfig object (e.g. from theme-custom-example.json)
132
137
  // - meta.colors, meta.typography, etc.: direct overrides from Studio (take precedence)
133
- const fromThemeConfig = (newMeta as { themeConfig?: object }).themeConfig || {};
134
- const direct = {
138
+ const fromThemeConfig = newMeta.themeConfig || {};
139
+ const direct: Partial<ThemeConfig> = {
135
140
  colors: newMeta.colors,
136
141
  typography: newMeta.typography,
137
142
  spacing: newMeta.spacing,
@@ -139,7 +144,8 @@ export class Lumina {
139
144
  effects: newMeta.effects,
140
145
  components: newMeta.components
141
146
  };
142
- const overrides = deepMerge(fromThemeConfig as Record<string, unknown>, direct as Record<string, unknown>) as ThemeConfig;
147
+ const base = (fromThemeConfig && typeof fromThemeConfig === 'object' && !Array.isArray(fromThemeConfig) ? fromThemeConfig : {});
148
+ const overrides = deepMerge<ThemeConfig>(base as ThemeConfig, direct);
143
149
 
144
150
  // Resolve theme: deck.theme (top-level, e.g. from theme-ocean.json) takes precedence over
145
151
  // meta.theme, then constructor options. Ensures examples and loaded decks keep their theme.
@@ -208,13 +214,17 @@ export class Lumina {
208
214
  * engine.load({ meta: { title: "Q4 Review" }, slides: [ { type: "statement", title: "Intro" } ] });
209
215
  */
210
216
  public load(deckData: Deck) {
211
- if (!deckData || !Array.isArray((deckData as any)?.slides)) {
217
+ if (!deckData || !Array.isArray(deckData.slides)) {
212
218
  bus.emit('error', new Error('Invalid deck: expected { meta?, slides: [] }'));
213
219
  return;
214
220
  }
215
221
  try {
216
222
  this.store.loadDeck(deckData);
217
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));
227
+ }
218
228
  this.syncSpeakerNotes();
219
229
  } catch (e) {
220
230
  bus.emit('error', e instanceof Error ? e : new Error(String(e)));
@@ -230,7 +240,7 @@ export class Lumina {
230
240
  * @example
231
241
  * engine.patch({ meta: { title: "Updated Title" } });
232
242
  */
233
- public patch(diff: any) {
243
+ public patch(diff: Partial<Deck>) {
234
244
  this.store.patchDeck(diff);
235
245
  bus.emit('patch', { diff: diff ?? {} });
236
246
  this.syncSpeakerNotes();
@@ -257,7 +267,7 @@ export class Lumina {
257
267
  index: currentIndex,
258
268
  id: slide?.id || currentIndex,
259
269
  type: slide?.type,
260
- title: (slide && 'title' in slide ? (slide as any).title : null) || "(No Title)"
270
+ title: (typeof slide?.title === 'string' ? slide.title : null) || "(No Title)"
261
271
  },
262
272
  narrative: `User is currently on slide ${currentIndex + 1}. Session Flow: ${context || "Just started"}.`,
263
273
  engagementLevel: interest,
@@ -368,7 +378,8 @@ export class Lumina {
368
378
  if (!container) return null;
369
379
  const root = container.querySelector('[data-lumina-root]') || container;
370
380
  const escaped = id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
371
- return (root.querySelector(`[data-lumina-id="${escaped}"]`) as HTMLElement) || null;
381
+ const el = root.querySelector(`[data-lumina-id="${escaped}"]`);
382
+ return el instanceof HTMLElement ? el : null;
372
383
  }
373
384
 
374
385
  /**
@@ -422,28 +433,50 @@ export class Lumina {
422
433
  return createElementController(this.store, this, idOrSlide);
423
434
  }
424
435
 
436
+ /**
437
+ * Returns the element at the given path on the current slide. Shorthand for
438
+ * engine.element(engine.currentSlideIndex, path). Use when you don't need the slide index.
439
+ *
440
+ * @param path - Logical path (e.g. "title", "features.0", ["elements", 0, "elements", 1]).
441
+ * @returns ElementController for that element.
442
+ *
443
+ * @example
444
+ * engine.elementInCurrent("title").show();
445
+ * engine.elementInCurrent("features.0").animate({ from: { opacity: 0 }, to: { opacity: 1 }, duration: 0.5 });
446
+ *
447
+ * @see element
448
+ * @see elements
449
+ */
450
+ public elementInCurrent(path: string | ElementPath): ElementController {
451
+ const i = this.store.state.currentIndex ?? 0;
452
+ return this.element(i, path);
453
+ }
454
+
425
455
  /**
426
456
  * Returns all element ids for a slide. Use to discover which ids exist (e.g. for
427
457
  * meta.initialElementState, or to iterate and control elements). Each id can be passed to
428
458
  * 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)).
459
+ * resolveId (explicit id, slide.ids, or slide.id / elemId(slideIndex, ...path) as fallback).
430
460
  *
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).
461
+ * @param slideIndex - Zero-based slide index. Omit to use the current slide. If out of range or deck not loaded, returns [].
462
+ * @returns Array of id strings (e.g. ["s0-slide","s0-tag","s0-title","s0-subtitle"] or ["intro-slide","intro-tag",...] when slide.id is set).
433
463
  *
434
464
  * @example
435
- * const ids = engine.elements(0);
436
- * ids.forEach(id => { if (id.endsWith('-title')) engine.element(id).hide(); });
465
+ * engine.elements(); // current slide
466
+ * engine.elements(0);
467
+ * engine.elements().forEach(id => { if (id.endsWith('-title')) engine.element(id).hide(); });
437
468
  *
438
469
  * @see element
470
+ * @see elementInCurrent
439
471
  * @see getElementIds
440
472
  * @see resolveId
441
473
  * @see DeckMeta.initialElementState
442
474
  */
443
- public elements(slideIndex: number): string[] {
444
- const slide = this.store.state.deck?.slides[slideIndex];
475
+ public elements(slideIndex?: number): string[] {
476
+ const i = slideIndex ?? this.store.state.currentIndex ?? 0;
477
+ const slide = this.store.state.deck?.slides[i];
445
478
  if (!slide) return [];
446
- return getElementIds(slide, slideIndex);
479
+ return getElementIds(slide, i);
447
480
  }
448
481
 
449
482
  /**
@@ -529,13 +562,28 @@ export class Lumina {
529
562
  return new Promise((resolve) => setTimeout(resolve, ms));
530
563
  }
531
564
 
565
+ /**
566
+ * Merges meta.reveal and slide.reveal into options for revealInSequence. slide overrides meta.
567
+ * Used when auto-running reveal from declarative slide.reveal or meta.reveal in the deck JSON.
568
+ */
569
+ private getRevealOptionsFromDeck(
570
+ deck: { meta?: { reveal?: RevealInSequenceOptions }; slides?: ReadonlyArray<{ reveal?: RevealInSequenceOptions }> },
571
+ index: number
572
+ ): RevealInSequenceOptions {
573
+ const meta = deck.meta;
574
+ const slide = deck.slides?.[index];
575
+ const a = (meta?.reveal && typeof meta.reveal === 'object') ? meta.reveal : {};
576
+ const b = (slide?.reveal && typeof slide.reveal === 'object') ? slide.reveal : {};
577
+ return { ...a, ...b };
578
+ }
579
+
532
580
  /**
533
581
  * Reveals slide elements one by one with optional stagger and animation. Optionally
534
582
  * hides all first (hideFirst: true). Resolves when the sequence has been scheduled
535
583
  * (does not wait for the last animation to finish).
536
584
  *
537
585
  * @param slideIndex - Zero-based slide index. Omit to use the current slide.
538
- * @param options - Stagger delay, animation from/to, and filters (only, exclude).
586
+ * @param options - Stagger delay, animation from/to, and filters (only, exclude). Or from slide.reveal / meta.reveal when auto-run.
539
587
  * @returns Promise that resolves after the last element's scheduled reveal time.
540
588
  *
541
589
  * @example
@@ -554,6 +602,7 @@ export class Lumina {
554
602
  const delayMs = opts.delayMs ?? cfg.revealDelayMs;
555
603
  const animate = opts.animate !== false;
556
604
 
605
+ const motions = this.store.state.deck?.meta?.motions;
557
606
  const resolved = resolveAnimationFromInput(
558
607
  {
559
608
  preset: opts.preset,
@@ -563,7 +612,8 @@ export class Lumina {
563
612
  ease: opts.ease,
564
613
  duration: opts.duration,
565
614
  },
566
- { from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration }
615
+ { from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration },
616
+ motions
567
617
  );
568
618
 
569
619
  const steps = computeStagger({
@@ -628,7 +678,7 @@ export class Lumina {
628
678
  */
629
679
  public seekTo(progress: number, slideIndex?: number): this {
630
680
  const i = slideIndex ?? this.currentSlideIndex;
631
- const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
681
+ const slide = this.store.state.deck?.slides[i];
632
682
  const timeline = slide?.timelineTracks;
633
683
  this._timelineProgress = Math.max(0, Math.min(1, progress));
634
684
  bus.emit('timelineSeek', { progress: this._timelineProgress, slideIndex: i });
@@ -654,7 +704,7 @@ export class Lumina {
654
704
  */
655
705
  public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
656
706
  const i = slideIndex ?? this.currentSlideIndex;
657
- const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
707
+ const slide = this.store.state.deck?.slides[i];
658
708
  if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
659
709
  return [Promise.resolve(), () => {}];
660
710
  }
@@ -799,11 +849,8 @@ export class Lumina {
799
849
  const currentSlide = deck.slides[currentIndex];
800
850
  const nextSlide = deck.slides[currentIndex + 1];
801
851
 
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
- };
852
+ const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined =>
853
+ (slide && typeof slide.title === 'string' ? slide.title : undefined);
807
854
 
808
855
  this.speakerChannel.send({
809
856
  action: 'state',
@@ -232,7 +232,7 @@ function pickCanonical(src: Record<string, unknown> | null | undefined): Partial
232
232
  * @see ResolvedTransitionConfig
233
233
  * @see LuminaAnimationOptions
234
234
  */
235
- export function resolveTransitionConfig(store: LuminaStore | null | undefined, slideData?: BaseSlideData | null): ResolvedTransitionConfig {
235
+ export function resolveTransitionConfig(store: LuminaStore | null | undefined, slideData?: Readonly<BaseSlideData> | null): ResolvedTransitionConfig {
236
236
  const base = { ...ANIMATION_DEFAULTS } as unknown as Record<string, unknown>;
237
237
 
238
238
  const opt = (store?.state?.options?.animation || {}) as Record<string, unknown>;
@@ -40,7 +40,7 @@ export function createElementController(
40
40
  }
41
41
 
42
42
  function getClass(): string {
43
- return (store.state.elementState[id]?.class as string) || '';
43
+ return store.state.elementState[id]?.class ?? '';
44
44
  }
45
45
 
46
46
  const ctrl: IElementController = {
@@ -121,12 +121,17 @@ export function createElementController(
121
121
  const vb = keyframes[String(b)] ?? {};
122
122
  const t = (progress - a) / (b - a);
123
123
  state = {};
124
- for (const prop of ['opacity', 'x', 'y', 'scale', 'rotate'] as const) {
124
+ const numericKeys = ['opacity', 'x', 'y', 'scale', 'rotate'] as const;
125
+ for (const prop of numericKeys) {
125
126
  const pa = va[prop];
126
127
  const pb = vb[prop];
127
- if (typeof pa === 'number' && typeof pb === 'number') (state as Record<string, number>)[prop] = pa + (pb - pa) * t;
128
- else if (pb !== undefined) (state as Record<string, unknown>)[prop] = pb;
129
- else if (pa !== undefined) (state as Record<string, unknown>)[prop] = pa;
128
+ if (typeof pa === 'number' && typeof pb === 'number') {
129
+ state[prop] = pa + (pb - pa) * t;
130
+ } else if (typeof pb === 'number') {
131
+ state[prop] = pb;
132
+ } else if (typeof pa === 'number') {
133
+ state[prop] = pa;
134
+ }
130
135
  }
131
136
  if (typeof vb.visible === 'boolean') state.visible = vb.visible;
132
137
  else if (typeof va.visible === 'boolean') state.visible = va.visible;
@@ -153,14 +158,15 @@ export function createElementController(
153
158
  const el = engine.getElementById(id);
154
159
  if (!el) return;
155
160
  const cfg = resolveTransitionConfig(store, undefined);
161
+ const motions = store.state.deck?.meta?.motions;
156
162
  const { from, to, ease, duration } = resolveAnimationFromInput(opts, {
157
163
  duration: cfg.elementDuration,
158
164
  ease: cfg.elementEase,
159
- });
165
+ }, motions);
160
166
  const cb = onCompleteOverride ?? opts.onComplete;
161
- const toVars = { ...to, duration, ease, overwrite: true, onComplete: cb } as gsap.TweenVars;
167
+ const toVars: gsap.TweenVars = { ...to, duration, ease, overwrite: true, onComplete: cb };
162
168
  if (from && Object.keys(from).length) {
163
- gsap.fromTo(el, from as gsap.TweenVars, toVars);
169
+ gsap.fromTo(el, from, toVars);
164
170
  } else {
165
171
  gsap.to(el, toVars);
166
172
  }
@@ -40,7 +40,7 @@ export function pathToKey(path: ElementPath): string {
40
40
  * @param path - Path segments (e.g. ['features', 0] or ['elements', 0, 'elements', 1]).
41
41
  * @returns The value at that path, or undefined if missing.
42
42
  */
43
- export function getValueAt(slide: BaseSlideData, path: ElementPath): unknown {
43
+ export function getValueAt(slide: Readonly<BaseSlideData>, path: ElementPath): unknown {
44
44
  if (!slide || !path.length) return undefined;
45
45
  return getByPath(slide, pathToKey(path));
46
46
  }
@@ -71,7 +71,8 @@ export function parsePath(input: string | ElementPath): ElementPath {
71
71
  * 1. Path [] or ['slide'] → slide.id or elemId(slideIndex, 'slide').
72
72
  * 2. If the value at path is an object with string `id` → use it (e.g. feature.id, node.id).
73
73
  * 3. If slide.ids[pathToKey(path)] exists → use it (supports compound keys: 'tag', 'features.0', 'elements.0.elements.1').
74
- * 4. Otherwise → elemId(slideIndex, ...path) (e.g. "s0-tag", "s1-elements-0-elements-1").
74
+ * 4. Otherwise → if slide.id is set, "{slide.id}-{path}"; else elemId(slideIndex, ...path)
75
+ * (e.g. "intro-tag" with slide.id "intro", or "s0-tag" when no slide.id).
75
76
  *
76
77
  * @param slide - The slide data.
77
78
  * @param slideIndex - Zero-based slide index (for elemId fallback).
@@ -83,7 +84,7 @@ export function parsePath(input: string | ElementPath): ElementPath {
83
84
  * @see elemId
84
85
  */
85
86
  export function resolveId(
86
- slide: BaseSlideData,
87
+ slide: Readonly<BaseSlideData>,
87
88
  slideIndex: number,
88
89
  path: ElementPath
89
90
  ): string {
@@ -99,11 +100,16 @@ export function resolveId(
99
100
  if (key && slideAny?.ids?.[key]) {
100
101
  return slideAny.ids[key];
101
102
  }
103
+ // Fallback: use slide.id as namespace when present, so IDs stay stable when slides are
104
+ // inserted, removed or reordered. Otherwise s{N}-{path} (same as before).
105
+ if (slideAny?.id != null && slideAny.id !== '') {
106
+ return `${slideAny.id}-${path.map(String).join('-')}`;
107
+ }
102
108
  return elemId(slideIndex, ...path);
103
109
  }
104
110
 
105
111
  /** Function that returns all logical element paths for a given slide. One per layout type. */
106
- export type PathGenerator = (slide: BaseSlideData) => ElementPath[];
112
+ export type PathGenerator = (slide: Readonly<BaseSlideData>) => ElementPath[];
107
113
 
108
114
  /** Map of slide.type → path generator. Extend to support new layouts. */
109
115
  const PATH_GENERATORS: Record<string, PathGenerator> = {
@@ -202,6 +208,6 @@ export function getElementPaths(slide: BaseSlideData): ElementPath[] {
202
208
  * @see Lumina.elements
203
209
  * @see DeckMeta.initialElementState
204
210
  */
205
- export function getElementIds(slide: BaseSlideData, slideIndex: number): string[] {
211
+ export function getElementIds(slide: Readonly<BaseSlideData>, slideIndex: number): string[] {
206
212
  return getElementPaths(slide).map((p) => resolveId(slide, slideIndex, p));
207
213
  }
package/src/core/store.ts CHANGED
@@ -267,7 +267,7 @@ export function createStore(initialOptions: LuminaOptions = {}) {
267
267
  * Feature: Diff Updates
268
268
  * Patches the current deck with partial data.
269
269
  */
270
- function patchDeck(partial: any) {
270
+ function patchDeck(partial: Partial<Deck>) {
271
271
  if (!state.deck) return;
272
272
  const merged = deepMerge(state.deck, partial);
273
273
  state.deck = ensureDragKeys(merged);
package/src/core/types.ts CHANGED
@@ -362,6 +362,17 @@ export interface AnimateOptions {
362
362
  onComplete?: () => void;
363
363
  }
364
364
 
365
+ /**
366
+ * Motion definition for meta.motions. Defines from/to/ease (and optional duration) for
367
+ * use when reveal.motion or animate({ motion: id }) references it. Overrides built-in presets when same id.
368
+ */
369
+ export interface MotionDef {
370
+ from?: Record<string, string | number>;
371
+ to?: Record<string, string | number>;
372
+ ease?: string;
373
+ duration?: number;
374
+ }
375
+
365
376
  /**
366
377
  * Options for `Lumina.revealInSequence`. Controls staggered reveal of slide elements.
367
378
  *
@@ -401,9 +412,9 @@ export interface RevealInSequenceOptions {
401
412
  /** If true, hide all elements before starting the sequence. Default: true */
402
413
  hideFirst?: boolean;
403
414
  /** Only reveal these element ids (subset). Order follows slide elements. */
404
- only?: string[];
415
+ only?: readonly string[];
405
416
  /** Exclude these element ids from the sequence. */
406
- exclude?: string[];
417
+ exclude?: readonly string[];
407
418
  }
408
419
 
409
420
  /**
@@ -437,8 +448,8 @@ export type TimelineKeyframes = Record<string, TimelineKeyframeState>;
437
448
  export type TimelineTracks = Record<string, TimelineKeyframes>;
438
449
 
439
450
  /**
440
- * Fluent API to control one slide element in real time. Returned by `engine.element(id)`
441
- * and `engine.element(slideIndex, path)`. All methods return `this` for chaining.
451
+ * Fluent API to control one slide element in real time. Returned by `engine.element(id)`,
452
+ * `engine.element(slideIndex, path)`, and `engine.elementInCurrent(path)`. All methods return `this` for chaining.
442
453
  *
443
454
  * Use for: starting with an empty slide and revealing items in order, hiding highlights,
444
455
  * animating entrances on demand, or syncing with voice/agent.
@@ -451,6 +462,7 @@ export type TimelineTracks = Record<string, TimelineKeyframes>;
451
462
  * engine.element(0, 'subtitle').show();
452
463
  *
453
464
  * @see Lumina.element
465
+ * @see Lumina.elementInCurrent
454
466
  * @see Lumina.elements
455
467
  * @see ElementState
456
468
  * @see AnimateOptions
@@ -515,6 +527,10 @@ interface SlideBase {
515
527
  backgroundOpacity?: number;
516
528
  /** Optional custom CSS class for the slide content container. */
517
529
  class?: string;
530
+ /** Optional title used by exportState and speaker notes when the slide type has no required title. */
531
+ title?: string;
532
+ /** Declarative reveal options. When present, engine runs revealInSequence on ready (slide 0) and on slideChange. */
533
+ reveal?: RevealInSequenceOptions;
518
534
  }
519
535
 
520
536
  /**
@@ -852,6 +868,12 @@ export interface DeckMeta {
852
868
  * defaultVisible: false = start all elements hidden; override per-id via initialElementState.
853
869
  */
854
870
  elementControl?: { defaultVisible?: boolean };
871
+ /** Default reveal options for all slides. Overridden by slide.reveal. */
872
+ reveal?: RevealInSequenceOptions;
873
+ /** Motion definitions (id -> { from, to, ease?, duration? }). Used when reveal.motion or animate({ motion: id }). */
874
+ motions?: Record<string, MotionDef>;
875
+ /** Full or partial theme overrides (e.g. from theme-custom-example.json). */
876
+ themeConfig?: ThemeConfig | object;
855
877
  [key: string]: any;
856
878
  }
857
879
 
@@ -1408,7 +1430,7 @@ export interface LuminaOptions {
1408
1430
  selector?: string;
1409
1431
  /** Whether to loop back to the start after the last slide. */
1410
1432
  loop?: boolean;
1411
- /** Master switch for slide-by-slide navigation. When false, the on-screen prev/next buttons are disabled and keyboard/touch do nothing. When true, keyboard and touch can be toggled via `keyboard` and `touch`; the nav buttons are shown/hidden with `ui.showControls`. Default: true. */
1433
+ /** Master switch for slide-by-slide navigation. When false, the on-screen prev/next arrow buttons are hidden and keyboard/touch do nothing. When true, keyboard and touch can be toggled via `keyboard` and `touch`; the nav buttons are shown/hidden with `ui.showControls`. Default: true. */
1412
1434
  navigation?: boolean;
1413
1435
  /** Enable or disable keyboard shortcuts for next/prev. Only has effect when `navigation` is true. Default: true. */
1414
1436
  keyboard?: boolean;
@@ -1467,7 +1489,7 @@ export type LuminaEventType =
1467
1489
  export interface SlideChangePayload {
1468
1490
  index: number;
1469
1491
  previousIndex: number;
1470
- slide: BaseSlideData;
1492
+ slide: Readonly<BaseSlideData>;
1471
1493
  }
1472
1494
 
1473
1495
  /**
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * **Deck:** `{ meta: { title, initialElementState?, elementControl?, effects? }, slides: [...] }`
11
11
  * Slide types: statement, features, half, timeline, steps, flex, chart, diagram, custom, video.
12
12
  *
13
- * **Element control:** `engine.element(id)` or `engine.element(slideIndex, path)` → ElementController:
13
+ * **Element control:** `engine.element(id)`, `engine.element(slideIndex, path)`, or `engine.elementInCurrent(path)` → ElementController:
14
14
  * `.show()`, `.hide()`, `.toggle()`, `.opacity(n)`, `.transform(s)`, `.animate({ preset?, from?, to?, duration?, ease? })`.
15
15
  * Presets: fadeUp, fadeIn, scaleIn, slideLeft, slideRight, zoomIn, blurIn, spring, drop, fadeOut. `to` optional when using preset.
16
16
  * Ids: `engine.elements(slideIndex)` or `s{N}-{path}` (e.g. s0-tag, s1-features-0). `meta.initialElementState`:
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * **Events:** `engine.on("ready"|"slideChange"|"action"|"error"|"patch"|"destroy"|"navigate"|"themeChange"|"speakerNotesOpen"|"speakerNotesClose"|"timelineSeek"|"timelineComplete"|"revealStart"|"revealComplete"|"revealElement", handler)`. `engine.emitError(err)` to forward errors.
32
32
  * **State for LLM:** `engine.exportState()` → `{ currentSlide, narrative, history }`.
33
- * **Key-value store:** `engine.data.get(key)`, `engine.data.set(key, value)`, `engine.data.has(key)`, `engine.data.delete(key)`, `engine.data.clear()`, `engine.data.keys()`. Persists across deck loads.
33
+ * **Key-value store:** `engine.data.get(key)`, `engine.data.set(key, value)`, `engine.data.has(key)`, `engine.data.delete(key)`, `engine.data.clear()`, `engine.data.keys()`. Persists across deck loads. **Template tags:** any slide string supports `{{key}}` resolved from engine.data; updates reactively when data changes. Helpers: `interpolateString`, `interpolateObject`.
34
34
  *
35
35
  * **Streaming:** `parsePartialJson(buffer)` then `engine.load(json)`. Or `createDebouncedLoader(engine.load, ms)`.
36
36
  * **Prompts:** `generateSystemPrompt({ mode, includeSchema, includeTheming })`, `generateThemePrompt()`.
@@ -146,6 +146,9 @@ export {
146
146
  type PresetDef,
147
147
  } from './animation';
148
148
 
149
+ // Template interpolation: {{tag}} in slide strings resolved from engine.data
150
+ export { interpolateString, interpolateObject } from './utils/templateInterpolation';
151
+
149
152
  // Element control: resolution and discovery (for engine.element(id), engine.element(slideIndex, path), engine.elements(slideIndex))
150
153
  export { elemId } from './core/elementId';
151
154
  export {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Template interpolation for Lumina slide content.
3
+ *
4
+ * Replaces `{{key}}` placeholders in strings with values from a key-value store
5
+ * (e.g. engine.data / store.userData). When the store changes, any component
6
+ * that uses the interpolated result will reactively update.
7
+ *
8
+ * @example
9
+ * interpolateString('Hello {{name}}', { name: 'World' }) // 'Hello World'
10
+ * interpolateObject({ title: '{{product}}', subtitle: 'v{{version}}' }, { product: 'Lumina', version: 1 })
11
+ * // { title: 'Lumina', subtitle: 'v1' }
12
+ */
13
+
14
+ /** Matches {{key}} or {{ key }}. Key: alphanumeric, underscore. */
15
+ const TAG_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
16
+
17
+ /**
18
+ * Replaces all {{key}} placeholders in a string with values from `data`.
19
+ * Missing keys render as empty string. Non-string values are coerced with String().
20
+ *
21
+ * @param str - Raw string possibly containing `{{key}}` tags.
22
+ * @param data - Key-value map (e.g. engine.data / store.state.userData).
23
+ * @returns Interpolated string.
24
+ */
25
+ export function interpolateString(str: string, data: Record<string, unknown>): string {
26
+ if (typeof str !== 'string') return str;
27
+ return str.replace(TAG_RE, (_, key: string) => {
28
+ const v = data[key];
29
+ return v == null ? '' : String(v);
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Recursively interpolates all string values in an object/array with `{{key}}`
35
+ * placeholders. Other types (number, boolean, null, etc.) are returned unchanged.
36
+ * Does not mutate the input; returns a new structure.
37
+ *
38
+ * @param obj - Object, array, or primitive (strings are interpolated).
39
+ * @param data - Key-value map (e.g. engine.data / store.state.userData).
40
+ * @returns New structure with all strings interpolated.
41
+ */
42
+ export function interpolateObject<T>(obj: T, data: Record<string, unknown>): T {
43
+ if (obj == null) return obj;
44
+ if (typeof obj === 'string') return interpolateString(obj, data) as T;
45
+ if (Array.isArray(obj)) return obj.map((item) => interpolateObject(item, data)) as T;
46
+ if (typeof obj === 'object' && obj.constructor === Object) {
47
+ return Object.fromEntries(
48
+ Object.entries(obj).map(([k, v]) => [k, interpolateObject(v, data)])
49
+ ) as T;
50
+ }
51
+ return obj;
52
+ }