lumina-slides 9.0.0 → 9.0.2

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
  /**
@@ -529,13 +540,28 @@ export class Lumina {
529
540
  return new Promise((resolve) => setTimeout(resolve, ms));
530
541
  }
531
542
 
543
+ /**
544
+ * Merges meta.reveal and slide.reveal into options for revealInSequence. slide overrides meta.
545
+ * Used when auto-running reveal from declarative slide.reveal or meta.reveal in the deck JSON.
546
+ */
547
+ private getRevealOptionsFromDeck(
548
+ deck: { meta?: { reveal?: RevealInSequenceOptions }; slides?: ReadonlyArray<{ reveal?: RevealInSequenceOptions }> },
549
+ index: number
550
+ ): RevealInSequenceOptions {
551
+ const meta = deck.meta;
552
+ const slide = deck.slides?.[index];
553
+ const a = (meta?.reveal && typeof meta.reveal === 'object') ? meta.reveal : {};
554
+ const b = (slide?.reveal && typeof slide.reveal === 'object') ? slide.reveal : {};
555
+ return { ...a, ...b };
556
+ }
557
+
532
558
  /**
533
559
  * Reveals slide elements one by one with optional stagger and animation. Optionally
534
560
  * hides all first (hideFirst: true). Resolves when the sequence has been scheduled
535
561
  * (does not wait for the last animation to finish).
536
562
  *
537
563
  * @param slideIndex - Zero-based slide index. Omit to use the current slide.
538
- * @param options - Stagger delay, animation from/to, and filters (only, exclude).
564
+ * @param options - Stagger delay, animation from/to, and filters (only, exclude). Or from slide.reveal / meta.reveal when auto-run.
539
565
  * @returns Promise that resolves after the last element's scheduled reveal time.
540
566
  *
541
567
  * @example
@@ -554,6 +580,7 @@ export class Lumina {
554
580
  const delayMs = opts.delayMs ?? cfg.revealDelayMs;
555
581
  const animate = opts.animate !== false;
556
582
 
583
+ const motions = this.store.state.deck?.meta?.motions;
557
584
  const resolved = resolveAnimationFromInput(
558
585
  {
559
586
  preset: opts.preset,
@@ -563,7 +590,8 @@ export class Lumina {
563
590
  ease: opts.ease,
564
591
  duration: opts.duration,
565
592
  },
566
- { from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration }
593
+ { from: { opacity: 0, y: 16 }, to: { opacity: 1, y: 0 }, ease: cfg.revealEase, duration: cfg.revealDuration },
594
+ motions
567
595
  );
568
596
 
569
597
  const steps = computeStagger({
@@ -628,7 +656,7 @@ export class Lumina {
628
656
  */
629
657
  public seekTo(progress: number, slideIndex?: number): this {
630
658
  const i = slideIndex ?? this.currentSlideIndex;
631
- const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
659
+ const slide = this.store.state.deck?.slides[i];
632
660
  const timeline = slide?.timelineTracks;
633
661
  this._timelineProgress = Math.max(0, Math.min(1, progress));
634
662
  bus.emit('timelineSeek', { progress: this._timelineProgress, slideIndex: i });
@@ -654,7 +682,7 @@ export class Lumina {
654
682
  */
655
683
  public playTimeline(durationSeconds = 5, slideIndex?: number): [Promise<void>, () => void] {
656
684
  const i = slideIndex ?? this.currentSlideIndex;
657
- const slide = this.store.state.deck?.slides[i] as BaseSlideData & { timelineTracks?: TimelineTracks } | undefined;
685
+ const slide = this.store.state.deck?.slides[i];
658
686
  if (!slide?.timelineTracks || typeof slide.timelineTracks !== 'object') {
659
687
  return [Promise.resolve(), () => {}];
660
688
  }
@@ -799,11 +827,8 @@ export class Lumina {
799
827
  const currentSlide = deck.slides[currentIndex];
800
828
  const nextSlide = deck.slides[currentIndex + 1];
801
829
 
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
- };
830
+ const getSlideTitle = (slide: BaseSlideData | undefined): string | undefined =>
831
+ (slide && typeof slide.title === 'string' ? slide.title : undefined);
807
832
 
808
833
  this.speakerChannel.send({
809
834
  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
  }
@@ -83,7 +83,7 @@ export function parsePath(input: string | ElementPath): ElementPath {
83
83
  * @see elemId
84
84
  */
85
85
  export function resolveId(
86
- slide: BaseSlideData,
86
+ slide: Readonly<BaseSlideData>,
87
87
  slideIndex: number,
88
88
  path: ElementPath
89
89
  ): string {
@@ -103,7 +103,7 @@ export function resolveId(
103
103
  }
104
104
 
105
105
  /** Function that returns all logical element paths for a given slide. One per layout type. */
106
- export type PathGenerator = (slide: BaseSlideData) => ElementPath[];
106
+ export type PathGenerator = (slide: Readonly<BaseSlideData>) => ElementPath[];
107
107
 
108
108
  /** Map of slide.type → path generator. Extend to support new layouts. */
109
109
  const PATH_GENERATORS: Record<string, PathGenerator> = {
@@ -202,6 +202,6 @@ export function getElementPaths(slide: BaseSlideData): ElementPath[] {
202
202
  * @see Lumina.elements
203
203
  * @see DeckMeta.initialElementState
204
204
  */
205
- export function getElementIds(slide: BaseSlideData, slideIndex: number): string[] {
205
+ export function getElementIds(slide: Readonly<BaseSlideData>, slideIndex: number): string[] {
206
206
  return getElementPaths(slide).map((p) => resolveId(slide, slideIndex, p));
207
207
  }
package/src/core/store.ts CHANGED
@@ -114,10 +114,14 @@ function deepMerge(target: any, source: any) {
114
114
  * @returns A store object containing the reactive state and methods.
115
115
  */
116
116
  export function createStore(initialOptions: LuminaOptions = {}) {
117
+ const baseOptions = { ...DEFAULT_OPTIONS, ...initialOptions };
118
+ if (initialOptions && typeof initialOptions.keys === 'object') {
119
+ baseOptions.keys = { ...DEFAULT_OPTIONS.keys!, ...initialOptions.keys };
120
+ }
117
121
  const state = reactive<LuminaState>({
118
122
  deck: null,
119
123
  currentIndex: 0,
120
- options: { ...DEFAULT_OPTIONS, ...initialOptions },
124
+ options: baseOptions,
121
125
  isReady: false,
122
126
  actionHistory: [],
123
127
  elementState: {},
@@ -263,7 +267,7 @@ export function createStore(initialOptions: LuminaOptions = {}) {
263
267
  * Feature: Diff Updates
264
268
  * Patches the current deck with partial data.
265
269
  */
266
- function patchDeck(partial: any) {
270
+ function patchDeck(partial: Partial<Deck>) {
267
271
  if (!state.deck) return;
268
272
  const merged = deepMerge(state.deck, partial);
269
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
  /**
@@ -515,6 +526,10 @@ interface SlideBase {
515
526
  backgroundOpacity?: number;
516
527
  /** Optional custom CSS class for the slide content container. */
517
528
  class?: string;
529
+ /** Optional title used by exportState and speaker notes when the slide type has no required title. */
530
+ title?: string;
531
+ /** Declarative reveal options. When present, engine runs revealInSequence on ready (slide 0) and on slideChange. */
532
+ reveal?: RevealInSequenceOptions;
518
533
  }
519
534
 
520
535
  /**
@@ -852,6 +867,12 @@ export interface DeckMeta {
852
867
  * defaultVisible: false = start all elements hidden; override per-id via initialElementState.
853
868
  */
854
869
  elementControl?: { defaultVisible?: boolean };
870
+ /** Default reveal options for all slides. Overridden by slide.reveal. */
871
+ reveal?: RevealInSequenceOptions;
872
+ /** Motion definitions (id -> { from, to, ease?, duration? }). Used when reveal.motion or animate({ motion: id }). */
873
+ motions?: Record<string, MotionDef>;
874
+ /** Full or partial theme overrides (e.g. from theme-custom-example.json). */
875
+ themeConfig?: ThemeConfig | object;
855
876
  [key: string]: any;
856
877
  }
857
878
 
@@ -1273,7 +1294,10 @@ export interface ThemeConfig {
1273
1294
  }
1274
1295
 
1275
1296
  /**
1276
- * Custom keyboard shortcuts.
1297
+ * Custom key bindings for next/prev slide navigation.
1298
+ * Values must be `KeyboardEvent.key` strings (e.g. 'ArrowRight', ' ', 'Enter', 'Backspace').
1299
+ * When you pass `keys` in options, it is merged with defaults: you can override only
1300
+ * `next` or only `prev` and the other keeps its default.
1277
1301
  */
1278
1302
  export interface LuminaKeyBindings {
1279
1303
  next: string[];
@@ -1287,7 +1311,8 @@ export interface LuminaUIOptions {
1287
1311
  visible?: boolean; // Global UI visibility
1288
1312
  showProgressBar?: boolean;
1289
1313
  showSlideCount?: boolean;
1290
- showControls?: boolean; // Next/Prev buttons
1314
+ /** Show or hide the prev/next (and speaker-notes) controls in the footer. When hidden, keyboard and touch still work if `navigation` and `keyboard`/`touch` allow. Default: true. */
1315
+ showControls?: boolean;
1291
1316
  }
1292
1317
 
1293
1318
  /**
@@ -1404,11 +1429,11 @@ export interface LuminaOptions {
1404
1429
  selector?: string;
1405
1430
  /** Whether to loop back to the start after the last slide. */
1406
1431
  loop?: boolean;
1407
- /** Enable/Disable keyboard navigation. */
1432
+ /** 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. */
1408
1433
  navigation?: boolean;
1409
- /** Enable/Disable keyboard shortcuts. */
1434
+ /** Enable or disable keyboard shortcuts for next/prev. Only has effect when `navigation` is true. Default: true. */
1410
1435
  keyboard?: boolean;
1411
- /** Enable/Disable touch swipe gestures. */
1436
+ /** Enable or disable touch/swipe for next/prev. Only has effect when `navigation` is true. Default: true. */
1412
1437
  touch?: boolean;
1413
1438
  /** Enable debug logs. */
1414
1439
  debug?: boolean;
@@ -1416,7 +1441,7 @@ export interface LuminaOptions {
1416
1441
  theme?: ThemeConfig | string;
1417
1442
  /** UI element toggles. */
1418
1443
  ui?: LuminaUIOptions;
1419
- /** Custom key bindings. */
1444
+ /** Custom key bindings for next/prev. Example: `keys: { next: ['ArrowRight',' ','Enter'], prev: ['ArrowLeft','Backspace'] }`. Use `KeyboardEvent.key` strings. Merged with defaults at init and in `setOptions`, so you can pass e.g. `keys: { prev: ['Backspace'] }` to change only prev. */
1420
1445
  keys?: LuminaKeyBindings;
1421
1446
  /** Animation settings. */
1422
1447
  animation?: LuminaAnimationOptions;
@@ -1463,7 +1488,7 @@ export type LuminaEventType =
1463
1488
  export interface SlideChangePayload {
1464
1489
  index: number;
1465
1490
  previousIndex: number;
1466
- slide: BaseSlideData;
1491
+ slide: Readonly<BaseSlideData>;
1467
1492
  }
1468
1493
 
1469
1494
  /**
package/src/index.ts CHANGED
@@ -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
+ }
@@ -54,6 +54,29 @@
54
54
  class="px-3 py-1.5 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
55
55
  @click="onTimelinePlay">Play</button>
56
56
  </div>
57
+
58
+ <!-- Template Tags: edit engine.data and see slides update (layout-template-tags example) -->
59
+ <div v-if="showTemplateTagsUI" class="fixed bottom-0 left-0 right-0 z-[90] flex items-center gap-4 px-4 py-3 bg-black/90 border-t border-white/10 backdrop-blur flex-wrap">
60
+ <span class="text-white/50 text-xs uppercase tracking-wider">engine.data</span>
61
+ <label class="flex items-center gap-2 text-sm text-white/80">
62
+ Product
63
+ <input v-model="templateProduct" type="text" placeholder="product"
64
+ class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
65
+ @input="onTemplateDataInput('product', ($event.target as HTMLInputElement).value)" />
66
+ </label>
67
+ <label class="flex items-center gap-2 text-sm text-white/80">
68
+ Version
69
+ <input v-model="templateVersion" type="text" placeholder="version"
70
+ class="w-24 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
71
+ @input="onTemplateDataInput('version', ($event.target as HTMLInputElement).value)" />
72
+ </label>
73
+ <label class="flex items-center gap-2 text-sm text-white/80">
74
+ User
75
+ <input v-model="templateUser" type="text" placeholder="user"
76
+ class="w-28 px-2 py-1 rounded bg-white/10 border border-white/20 text-white text-sm focus:border-blue-500 focus:outline-none"
77
+ @input="onTemplateDataInput('user', ($event.target as HTMLInputElement).value)" />
78
+ </label>
79
+ </div>
57
80
  </div>
58
81
  </template>
59
82
 
@@ -85,16 +108,33 @@ const timelineCancelRef = ref<(() => void) | null>(null);
85
108
  const timelineIntervalRef = ref<ReturnType<typeof setInterval> | null>(null);
86
109
 
87
110
  const showTimelineUI = computed(() => {
88
- const id = route.params.id as string;
111
+ const p = route.params.id;
112
+ const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
89
113
  if (id !== 'animation-timeline') return false;
90
- const slide = loadedDeck.value?.slides[currentSlideIndex.value] as { timelineTracks?: unknown } | undefined;
114
+ const slide = loadedDeck.value?.slides[currentSlideIndex.value];
91
115
  return !!slide?.timelineTracks;
92
116
  });
93
117
 
118
+ // Template Tags example: edit engine.data and see slides update
119
+ const templateProduct = ref('Lumina');
120
+ const templateVersion = ref('2.0');
121
+ const templateUser = ref('World');
122
+ const showTemplateTagsUI = computed(() => {
123
+ const p = route.params.id;
124
+ const id = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
125
+ return id === 'layout-template-tags';
126
+ });
127
+ function onTemplateDataInput(key: string, value: string) {
128
+ engineRef.value?.data.set(key, value);
129
+ }
130
+
94
131
  function onTimelineInput(e: Event) {
95
- const v = parseFloat((e.target as HTMLInputElement).value);
96
- timelineProgress.value = v;
97
- engineRef.value?.seekTo(v / 100);
132
+ const t = e.target;
133
+ if (t instanceof HTMLInputElement) {
134
+ const v = parseFloat(t.value);
135
+ timelineProgress.value = v;
136
+ engineRef.value?.seekTo(v / 100);
137
+ }
98
138
  }
99
139
 
100
140
  function onTimelinePlay() {
@@ -163,7 +203,7 @@ async function onLoginAndDuplicate() {
163
203
  await loginWithGoogle();
164
204
  showLoginModal.value = false;
165
205
  await duplicateAndEdit();
166
- } catch (e: unknown) {
206
+ } catch (e) {
167
207
  const msg = e instanceof Error ? e.message : 'Sign-in failed. Please try again.';
168
208
  loginError.value = msg;
169
209
  } finally {
@@ -176,7 +216,8 @@ onMounted(async () => {
176
216
  initFirebase();
177
217
  } catch (e) { }
178
218
 
179
- const deckId = route.params.id as string;
219
+ const p = route.params.id;
220
+ const deckId = typeof p === 'string' ? p : (Array.isArray(p) && p[0] ? p[0] : '');
180
221
  if (!deckId) return;
181
222
 
182
223
  try {
@@ -208,7 +249,7 @@ onMounted(async () => {
208
249
  showControls: true,
209
250
  showProgressBar: true
210
251
  },
211
- theme: (deckData as Deck & { theme?: string }).theme || 'default'
252
+ theme: deckData.theme || 'default'
212
253
  });
213
254
  engineRef.value = engine;
214
255
 
@@ -227,29 +268,6 @@ onMounted(async () => {
227
268
  });
228
269
  }
229
270
 
230
- if (deckId === 'animation-presets') {
231
- const presets = ['fadeUp', 'scaleIn', 'spring'] as const;
232
- engine.on('ready', () => engine?.revealInSequence(0, { preset: presets[0], delayMs: 450 }));
233
- engine.on('slideChange', ({ index }) => {
234
- engine?.revealInSequence(index, { preset: presets[index] ?? 'fadeUp', delayMs: 450 });
235
- });
236
- }
237
-
238
- if (deckId === 'animation-stagger') {
239
- const modes = ['center-out', 'wave', 'random'] as const;
240
- engine.on('ready', () =>
241
- engine?.revealInSequence(0, { preset: 'scaleIn', staggerMode: modes[0], delayMs: 380 })
242
- );
243
- engine.on('slideChange', ({ index }) => {
244
- engine?.revealInSequence(index, {
245
- preset: 'scaleIn',
246
- staggerMode: modes[index] ?? 'sequential',
247
- randomSeed: index,
248
- delayMs: 380
249
- });
250
- });
251
- }
252
-
253
271
  if (deckId === 'animation-timeline') {
254
272
  engine.on('ready', () => {
255
273
  engine?.seekTo(0);
@@ -270,6 +288,12 @@ onMounted(async () => {
270
288
  });
271
289
  }
272
290
 
291
+ if (deckId === 'layout-template-tags') {
292
+ engine.data.set('product', templateProduct.value);
293
+ engine.data.set('version', templateVersion.value);
294
+ engine.data.set('user', templateUser.value);
295
+ }
296
+
273
297
  engine.load(deckData);
274
298
  } catch (error) {
275
299
  console.error("Failed to load deck:", error);