lumina-slides 8.9.4 → 9.0.0

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 +13207 -12659
  4. package/dist/lumina-slides.umd.cjs +215 -215
  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 +3267 -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 +461 -0
  103. package/src/core/theme.ts +666 -0
  104. package/src/core/types.ts +1611 -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,122 @@
1
+ <template>
2
+ <div
3
+ ref="container"
4
+ class="w-full min-h-full relative text-white"
5
+ :data-lumina-id="slideId"
6
+ v-show="elState.visible"
7
+ :class="[customClass, elState.class].filter(Boolean)"
8
+ :style="{ ...slideStyle, ...elState.style }">
9
+ <!-- Dynamic Background Layer -->
10
+ <div class="absolute inset-0 z-0 pointer-events-none overflow-hidden">
11
+
12
+ <!-- Background Image -->
13
+ <div v-if="bgType === 'image'" class="absolute inset-0 bg-cover bg-center transition-opacity duration-1000"
14
+ :style="bgImageStyle">
15
+ </div>
16
+
17
+ <!-- Background Video -->
18
+ <video v-else-if="bgType === 'video'"
19
+ class="absolute inset-0 w-full h-full object-cover transition-opacity duration-1000"
20
+ :style="{ opacity: bgOpacity }" :src="bgSrc" :poster="bgPoster" :autoplay="bgAutoplay" :loop="bgLoop"
21
+ :muted="bgMuted" playsinline>
22
+ </video>
23
+ <div v-if="bgType === 'video'"
24
+ class="absolute inset-0 bg-[var(--lumina-colors-background,black)]/30 pointer-events-none transition-opacity duration-1000">
25
+ </div>
26
+
27
+ <!-- Slot for extra background elements (orb, noise, etc) -->
28
+ <slot name="background"></slot>
29
+ </div>
30
+
31
+ <!-- Main Content -->
32
+ <div class="relative z-10 w-full h-full">
33
+ <slot></slot>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { ref, computed } from 'vue';
40
+ import { useTransition } from '../../composables/useTransition';
41
+ import { useElementState } from '../../composables/useElementState';
42
+ import { resolveId } from '../../core/elementResolver';
43
+ import type { BaseSlideData, VideoProperties } from '../../core/types';
44
+
45
+ /**
46
+ * BASE SLIDE COMPONENT
47
+ * The standard wrapper for all slide layouts.
48
+ */
49
+
50
+ const props = defineProps<{
51
+ data: BaseSlideData;
52
+ customClass?: string;
53
+ slideIndex?: number;
54
+ }>();
55
+
56
+ const slideId = computed(() => resolveId(props.data, props.slideIndex ?? 0, ['slide']));
57
+ const elState = useElementState(slideId);
58
+
59
+ const container = ref<HTMLElement | null>(null);
60
+
61
+ // Initialize Animation Logic - Now passing slide data for granular control
62
+ const { animateIn, animateOut } = useTransition(container, props.data);
63
+
64
+ defineExpose({ animateIn, animateOut });
65
+
66
+ // --- Background Logic ---
67
+
68
+ const slideStyle = computed(() => {
69
+ const bg = props.data.meta?.colors?.background;
70
+ return bg ? { backgroundColor: bg } : {};
71
+ });
72
+
73
+ const bgType = computed(() => {
74
+ if (!props.data.background) return null;
75
+ if (typeof props.data.background === 'string') return 'image';
76
+ if ((props.data.background as VideoProperties).type === 'video') return 'video';
77
+ return null;
78
+ });
79
+
80
+ const bgSrc = computed(() => {
81
+ if (!props.data.background) return '';
82
+ if (typeof props.data.background === 'string') return props.data.background;
83
+ return (props.data.background as VideoProperties).src;
84
+ });
85
+
86
+ const bgImageStyle = computed(() => {
87
+ if (bgType.value !== 'image') return {};
88
+ return {
89
+ backgroundImage: `url(${bgSrc.value})`,
90
+ opacity: bgOpacity.value,
91
+ backgroundSize: props.data.meta?.backgroundSize || 'cover'
92
+ };
93
+ });
94
+
95
+ const bgPoster = computed(() => {
96
+ if (bgType.value !== 'video') return undefined;
97
+ return (props.data.background as VideoProperties).poster;
98
+ });
99
+
100
+ const bgAutoplay = computed(() => {
101
+ if (bgType.value !== 'video') return undefined;
102
+ const vid = props.data.background as VideoProperties;
103
+ return vid.autoplay ?? true;
104
+ });
105
+
106
+ const bgLoop = computed(() => {
107
+ if (bgType.value !== 'video') return undefined;
108
+ const vid = props.data.background as VideoProperties;
109
+ return vid.loop ?? true;
110
+ });
111
+
112
+ const bgMuted = computed(() => {
113
+ if (bgType.value !== 'video') return undefined;
114
+ const vid = props.data.background as VideoProperties;
115
+ return vid.muted ?? true;
116
+ });
117
+
118
+ const bgOpacity = computed(() => {
119
+ return props.data.backgroundOpacity ?? 1;
120
+ });
121
+
122
+ </script>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ :data-lumina-id="luminaId"
5
+ :style="mergedStyle"
6
+ :class="[state.class, propClass].filter(Boolean)">
7
+ <slot />
8
+ </component>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ /**
13
+ * LuminaElement — Wrapper that applies element control state (visibility, opacity, transform,
14
+ * class, style) to its slot. Sets data-lumina-id so engine.getElementById and ElementController.animate
15
+ * can find the node. Use with :id="resolveId(data, slideIndex, path)" in layouts.
16
+ *
17
+ * @prop id - Element id (from resolveId, elemId, or explicit). Required.
18
+ * @prop tag - Root tag name. Default 'div'.
19
+ * @prop class - Extra class(es) merged with state.class.
20
+ * @prop style - Extra style merged with state.style (state overrides for opacity/transform).
21
+ *
22
+ * @see useElementState
23
+ * @see resolveId
24
+ * @see ElementState
25
+ */
26
+ import { computed, unref } from 'vue';
27
+ import { useElementState } from '../../composables/useElementState';
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Element id for engine.element(id) and data-lumina-id. From resolveId(slide, slideIndex, path). */
32
+ id: string;
33
+ /** HTML tag for the root. Default 'div'. */
34
+ tag?: string;
35
+ /** Additional CSS class(es). Merged with state.class from the store. */
36
+ class?: string | string[];
37
+ /** Additional inline style. state.style (opacity, transform, etc.) takes precedence. */
38
+ style?: Record<string, string | number>;
39
+ }>(),
40
+ { tag: 'div' }
41
+ );
42
+
43
+ const state = useElementState(() => props.id);
44
+
45
+ /** Only set data-lumina-id when id is a non-empty string; avoids data-lumina-id="undefined" or "". */
46
+ const luminaId = computed(() => {
47
+ const v = props.id;
48
+ if (v == null) return undefined;
49
+ const s = String(v).trim();
50
+ return s ? s : undefined;
51
+ });
52
+
53
+ const propClass = computed(() => {
54
+ const c = props.class;
55
+ if (Array.isArray(c)) return c.filter(Boolean).join(' ');
56
+ return c || '';
57
+ });
58
+
59
+ const mergedStyle = computed(() => {
60
+ const resolved = unref(state.style) ?? {};
61
+ const out = { ...(props.style || {}), ...resolved };
62
+ if ('opacity' in resolved && out.transition == null) {
63
+ out.transition = 'opacity var(--lumina-element-opacity-transition, 0.35s ease-out)';
64
+ }
65
+ return out;
66
+ });
67
+ </script>
@@ -0,0 +1,204 @@
1
+ <template>
2
+ <div class="relative w-full h-full group bg-black overflow-hidden" @mousemove="handleMouseMove"
3
+ @mouseleave="showControls = false">
4
+
5
+ <!-- Video Element -->
6
+ <video ref="videoRef" :class="['w-full h-full', objectFitClass]" :src="src" :poster="poster"
7
+ :autoplay="autoplay" :loop="loop" :muted="muted" playsinline @click="togglePlay"
8
+ @timeupdate="handleTimeUpdate" @loadedmetadata="handleMetadata" @ended="handleEnded">
9
+ </video>
10
+
11
+ <!-- Custom Controls Overlay -->
12
+ <transition name="fade">
13
+ <div v-if="controls && showControls"
14
+ class="absolute bottom-0 left-0 w-full p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent transition-opacity duration-300">
15
+
16
+ <!-- Progress Bar -->
17
+ <div class="w-full h-1 bg-white/20 rounded-full mb-4 cursor-pointer relative group/progress"
18
+ @click="seek">
19
+ <div class="absolute top-0 left-0 h-full rounded-full transition-all duration-100"
20
+ :style="{ width: `${progress}%`, backgroundColor: 'var(--lumina-colors-primary, #3b82f6)' }">
21
+ </div>
22
+ <!-- Hover Effect -->
23
+ <div class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity"
24
+ :style="{ left: `${progress}%` }"></div>
25
+ </div>
26
+
27
+ <!-- Controls Row -->
28
+ <div class="flex items-center justify-between text-white">
29
+ <div class="flex items-center gap-4">
30
+ <!-- Play/Pause -->
31
+ <button @click="togglePlay" class="hover:text-[var(--lumina-colors-primary)] transition">
32
+ <i :class="['ph-thin text-xl', isPlaying ? 'ph-pause' : 'ph-play']"></i>
33
+ </button>
34
+
35
+ <!-- Volume -->
36
+ <div class="flex items-center gap-2 group/vol">
37
+ <button @click="toggleMute"
38
+ class="hover:text-[var(--lumina-colors-primary)] transition w-6">
39
+ <i :class="['ph-thin', isMuted ? 'ph-speaker-slash' : 'ph-speaker-high']"></i>
40
+ </button>
41
+ <!-- Volume Slider (Hidden until hover) -->
42
+ <input type="range" min="0" max="1" step="0.1" v-model.number="volume"
43
+ class="w-0 overflow-hidden group-hover/vol:w-20 transition-all duration-300 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" />
44
+ </div>
45
+
46
+ <!-- Time -->
47
+ <span class="text-xs font-mono opacity-70">{{ formatTime(currentTime) }} / {{
48
+ formatTime(duration) }}</span>
49
+ </div>
50
+
51
+ <!-- Right Side -->
52
+ <div class="flex items-center gap-4">
53
+ <!-- Fullscreen -->
54
+ <button @click="toggleFullscreen" class="hover:text-[var(--lumina-colors-primary)] transition">
55
+ <i class="ph-thin ph-corners-out"></i>
56
+ </button>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </transition>
61
+
62
+ <!-- Big Play Button Overlay (when paused) -->
63
+ <div v-if="controls && !isPlaying && !hasPlayedOnce"
64
+ class="absolute inset-0 flex items-center justify-center pointer-events-none">
65
+ <div class="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm border border-white/10 flex items-center justify-center text-white pointer-events-auto cursor-pointer hover:scale-110 transition duration-300"
66
+ @click="togglePlay">
67
+ <i class="ph-thin ph-play text-2xl ml-1"></i>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </template>
72
+
73
+ <script setup lang="ts">
74
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
75
+
76
+ const props = defineProps({
77
+ src: { type: String, required: true },
78
+ poster: { type: String, default: '' },
79
+ autoplay: { type: Boolean, default: false },
80
+ loop: { type: Boolean, default: false },
81
+ muted: { type: Boolean, default: false },
82
+ controls: { type: Boolean, default: true },
83
+ objectFit: { type: String, default: 'cover' } // cover | contain
84
+ });
85
+
86
+ const videoRef = ref<HTMLVideoElement | null>(null);
87
+ const isPlaying = ref(false);
88
+ const isMuted = ref(props.muted);
89
+ const volume = ref(1);
90
+ const progress = ref(0);
91
+ const currentTime = ref(0);
92
+ const duration = ref(0);
93
+ const showControls = ref(false);
94
+ const hasPlayedOnce = ref(false);
95
+
96
+ let controlsTimeout: number | undefined;
97
+
98
+ const objectFitClass = computed(() => {
99
+ return props.objectFit === 'contain' ? 'object-contain' : 'object-cover';
100
+ });
101
+
102
+ // Format seconds to MM:SS
103
+ const formatTime = (seconds: number) => {
104
+ const m = Math.floor(seconds / 60);
105
+ const s = Math.floor(seconds % 60);
106
+ return `${m}:${s.toString().padStart(2, '0')}`;
107
+ };
108
+
109
+ const togglePlay = () => {
110
+ if (!videoRef.value) return;
111
+ if (videoRef.value.paused) {
112
+ videoRef.value.play();
113
+ isPlaying.value = true;
114
+ hasPlayedOnce.value = true;
115
+ } else {
116
+ videoRef.value.pause();
117
+ isPlaying.value = false;
118
+ }
119
+ };
120
+
121
+ const toggleMute = () => {
122
+ if (!videoRef.value) return;
123
+ videoRef.value.muted = !videoRef.value.muted;
124
+ isMuted.value = videoRef.value.muted;
125
+ if (isMuted.value) volume.value = 0;
126
+ else volume.value = 1;
127
+ };
128
+
129
+ const handleTimeUpdate = () => {
130
+ if (!videoRef.value) return;
131
+ currentTime.value = videoRef.value.currentTime;
132
+ progress.value = (currentTime.value / duration.value) * 100 || 0;
133
+ };
134
+
135
+ const handleMetadata = () => {
136
+ if (!videoRef.value) return;
137
+ duration.value = videoRef.value.duration;
138
+ if (props.autoplay) {
139
+ togglePlay();
140
+ }
141
+ };
142
+
143
+ const handleEnded = () => {
144
+ isPlaying.value = false;
145
+ if (props.loop && videoRef.value) {
146
+ videoRef.value.currentTime = 0;
147
+ togglePlay();
148
+ }
149
+ };
150
+
151
+ const seek = (e: MouseEvent) => {
152
+ if (!videoRef.value) return;
153
+ const bar = e.currentTarget as HTMLElement;
154
+ const percent = e.offsetX / bar.clientWidth;
155
+ videoRef.value.currentTime = percent * duration.value;
156
+ progress.value = percent * 100;
157
+ };
158
+
159
+ const toggleFullscreen = () => {
160
+ if (!videoRef.value) return;
161
+ const el = videoRef.value.parentElement;
162
+ if (!document.fullscreenElement && el) {
163
+ el.requestFullscreen();
164
+ } else {
165
+ document.exitFullscreen();
166
+ }
167
+ };
168
+
169
+ const handleMouseMove = () => {
170
+ showControls.value = true;
171
+ clearTimeout(controlsTimeout);
172
+ controlsTimeout = setTimeout(() => {
173
+ if (isPlaying.value) showControls.value = false;
174
+ }, 3000);
175
+ };
176
+
177
+ // Watch volume slider
178
+ watch(volume, (val) => {
179
+ if (!videoRef.value) return;
180
+ videoRef.value.volume = val;
181
+ isMuted.value = val === 0;
182
+ videoRef.value.muted = val === 0;
183
+ });
184
+
185
+ onMounted(() => {
186
+ // Sync initial muted state
187
+ if (videoRef.value) {
188
+ videoRef.value.muted = props.muted;
189
+ isMuted.value = props.muted;
190
+ }
191
+ });
192
+ </script>
193
+
194
+ <style scoped>
195
+ /* Custom Range Input Styling for Volume */
196
+ input[type=range] {
197
+ -webkit-appearance: none;
198
+ background: transparent;
199
+ }
200
+
201
+ input[type=range]:focus {
202
+ outline: none;
203
+ }
204
+ </style>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <component :is="resolvedLayout" :data="data" :slide-index="slideIndex" @action="emit('action', $event)" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import { computed, inject } from 'vue';
7
+ import { StoreKey } from '../../core/store';
8
+
9
+ const props = defineProps<{
10
+ data: any;
11
+ slideIndex?: number;
12
+ }>();
13
+
14
+ const emit = defineEmits<{ (e: 'action', payload: unknown): void }>();
15
+ const store = inject(StoreKey);
16
+ const slideIndex = computed(() => props.slideIndex ?? store?.state.currentIndex ?? 0);
17
+
18
+ // Module-level state to track variety across slide changes (Singleton per session)
19
+ // This resets on page reload, which is fine.
20
+ let lastAutoType: string | null = null;
21
+
22
+ const resolvedLayout = computed(() => {
23
+ const d = props.data || {};
24
+
25
+ // 1. Hard Heuristics (Explicit Data Structures)
26
+ const hasTimeline = Array.isArray(d.timeline) && d.timeline.length > 0;
27
+ const hasSteps = Array.isArray(d.steps) && d.steps.length > 0;
28
+ const hasFeatures = Array.isArray(d.features) && d.features.length > 0;
29
+ const hasImage = !!d.image;
30
+ const hasChart = !!d.chartType && !!d.data?.datasets;
31
+
32
+ let candidate = 'layout-statement'; // Default
33
+
34
+ // 2. Semantic Priority
35
+ if (hasChart) {
36
+ candidate = 'layout-chart';
37
+ } else if (hasTimeline) {
38
+ candidate = 'layout-timeline';
39
+ } else if (hasSteps) {
40
+ candidate = 'layout-steps';
41
+ } else if (hasFeatures) {
42
+ candidate = 'layout-features';
43
+ } else if (hasImage) {
44
+ candidate = 'layout-half';
45
+ } else {
46
+ // Text Only Analysis: title + subtitle + paragraphs. For a future "Document" layout
47
+ // we could use text length to prefer a layout with scrolling. Statement is best for now.
48
+ }
49
+
50
+ // Improvement: "Variety Enforcer"
51
+ // If we picked the same layout as last time, try to switch if possible.
52
+ if (candidate === lastAutoType && candidate === 'layout-statement' && hasImage) {
53
+ // If we defaulted to Statement but have an image, maybe we used Statement last time?
54
+ // Actually if hasImage is true, we picked Half.
55
+ // Let's say we have small text and no image.
56
+ // If we are repeating Statement, maybe we can't do much.
57
+ }
58
+
59
+ // Side-effect: Store choice (in onMounted or watcher? Computed is pure usually)
60
+ // We cannot change module state in computed easily without side effects.
61
+ // Putting it here is "okay" for this simple use case.
62
+ lastAutoType = candidate;
63
+
64
+ return candidate;
65
+ });
66
+
67
+ // Improvement: "Layout Downgrade Fallback" happens implicitly.
68
+ // We strictly only return 'layout-features' if `hasFeatures` is true.
69
+ // This prevents picking a layout that would break.
70
+
71
+ </script>