@veolab/discoverylab 1.4.3 → 1.6.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.
@@ -0,0 +1,183 @@
1
+ import "./chunk-R5U7XKVJ.js";
2
+
3
+ // src/core/export/infographic.ts
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
5
+ import { join, dirname, extname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ function findTemplate() {
8
+ const __dir = dirname(fileURLToPath(import.meta.url));
9
+ const possiblePaths = [
10
+ join(__dir, "infographic-template.html"),
11
+ join(__dir, "..", "export", "infographic-template.html"),
12
+ join(process.cwd(), "dist", "export", "infographic-template.html"),
13
+ join(process.cwd(), "src", "core", "export", "infographic-template.html")
14
+ ];
15
+ for (const p of possiblePaths) {
16
+ if (existsSync(p)) return p;
17
+ }
18
+ return null;
19
+ }
20
+ function imageToBase64(imagePath) {
21
+ if (!existsSync(imagePath)) return "";
22
+ const buffer = readFileSync(imagePath);
23
+ const ext = extname(imagePath).toLowerCase();
24
+ const mimeTypes = {
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".webp": "image/webp",
29
+ ".gif": "image/gif"
30
+ };
31
+ const mime = mimeTypes[ext] || "image/png";
32
+ return `data:${mime};base64,${buffer.toString("base64")}`;
33
+ }
34
+ function stripMarkdown(text) {
35
+ return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/_(.+?)_/g, "$1").replace(/`(.+?)`/g, "$1").replace(/^#{1,3}\s+/gm, "").replace(/^-\s+/gm, "").replace(/^\d+\.\s+/gm, "").replace(/\[(.+?)\]\(.*?\)/g, "$1").trim();
36
+ }
37
+ function collectFrameImages(framesDir, videoPath, projectsDir, projectId) {
38
+ const imageExts = /\.(png|jpg|jpeg|webp|gif)$/i;
39
+ const dirs = [
40
+ framesDir,
41
+ ...videoPath ? [join(videoPath, "screenshots"), videoPath] : [],
42
+ ...projectsDir && projectId ? [
43
+ join(projectsDir, "maestro-recordings", projectId, "screenshots"),
44
+ join(projectsDir, "web-recordings", projectId, "screenshots")
45
+ ] : []
46
+ ];
47
+ for (const dir of dirs) {
48
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
49
+ const files = readdirSync(dir).filter((f) => imageExts.test(f)).sort().map((f) => join(dir, f));
50
+ if (files.length > 0) return files;
51
+ }
52
+ return [];
53
+ }
54
+ function buildInfographicData(project, frameFiles, frameOcr, annotations) {
55
+ let flowSteps = [];
56
+ let uiElements = [];
57
+ if (project.aiSummary) {
58
+ const flowMatch = project.aiSummary.match(/## (?:User Flow|Likely User Flow)\n([\s\S]*?)(?=\n##|\n$|$)/);
59
+ if (flowMatch) {
60
+ flowSteps = (flowMatch[1].match(/^\d+\.\s+(.+)$/gm) || []).map((s) => s.replace(/^\d+\.\s+/, ""));
61
+ }
62
+ const uiMatch = project.aiSummary.match(/## (?:UI Elements Found|Key UI Elements)\n([\s\S]*?)(?=\n##|\n$|$)/);
63
+ if (uiMatch) {
64
+ uiElements = (uiMatch[1].match(/^-\s+(.+)$/gm) || []).map((s) => s.replace(/^-\s+/, ""));
65
+ }
66
+ }
67
+ const elementsPerFrame = Math.max(1, Math.ceil(uiElements.length / Math.max(frameFiles.length, 1)));
68
+ const hotspotColors = ["#818CF8", "#34D399", "#F59E0B", "#EC4899", "#06B6D4", "#8B5CF6"];
69
+ const hotspotPositions = [
70
+ { x: 50, y: 8 },
71
+ // top center (nav bar)
72
+ { x: 15, y: 30 },
73
+ // left middle
74
+ { x: 85, y: 30 },
75
+ // right middle
76
+ { x: 50, y: 50 },
77
+ // center
78
+ { x: 50, y: 85 },
79
+ // bottom center (tab bar)
80
+ { x: 15, y: 70 }
81
+ // bottom left
82
+ ];
83
+ const frames = frameFiles.map((filePath, i) => {
84
+ const rawStepName = annotations?.[i]?.label || flowSteps[i] || `Step ${i + 1}`;
85
+ const stepName = stripMarkdown(rawStepName);
86
+ const ocr = frameOcr[i]?.ocrText || "";
87
+ const rawDesc = flowSteps[i] || ocr.slice(0, 100) || `Screen ${i + 1}`;
88
+ const description = stripMarkdown(rawDesc);
89
+ const hotspots = [];
90
+ const startIdx = i * elementsPerFrame;
91
+ const frameElements = uiElements.slice(startIdx, startIdx + elementsPerFrame).slice(0, 4);
92
+ frameElements.forEach((el, j) => {
93
+ const cleanEl = stripMarkdown(el);
94
+ const pos = hotspotPositions[j % hotspotPositions.length];
95
+ hotspots.push({
96
+ id: String.fromCharCode(65 + j),
97
+ x_percent: pos.x,
98
+ y_percent: pos.y,
99
+ label: cleanEl.slice(0, 20),
100
+ title: cleanEl.slice(0, 40),
101
+ description: cleanEl,
102
+ color: hotspotColors[j % hotspotColors.length]
103
+ });
104
+ });
105
+ if (hotspots.length === 0 && ocr) {
106
+ const ocrWords = ocr.split(/\s+/).filter((w) => w.length > 3).slice(0, 3);
107
+ ocrWords.forEach((w, j) => {
108
+ const pos = hotspotPositions[j % hotspotPositions.length];
109
+ hotspots.push({
110
+ id: String.fromCharCode(65 + j),
111
+ x_percent: pos.x,
112
+ y_percent: pos.y,
113
+ label: w.slice(0, 15),
114
+ title: w,
115
+ description: `Text found: "${w}"`,
116
+ color: hotspotColors[j % hotspotColors.length]
117
+ });
118
+ });
119
+ }
120
+ return {
121
+ id: `frame-${i}`,
122
+ step_name: stepName,
123
+ description,
124
+ base64: imageToBase64(filePath),
125
+ baseline_status: "not_validated",
126
+ hotspots
127
+ };
128
+ });
129
+ return {
130
+ name: project.marketingTitle || project.name,
131
+ platform: project.platform || "unknown",
132
+ recorded_at: project.createdAt instanceof Date ? project.createdAt.toISOString() : typeof project.createdAt === "string" ? project.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
133
+ frames
134
+ };
135
+ }
136
+ function generateInfographicHtmlString(data) {
137
+ const templatePath = findTemplate();
138
+ if (!templatePath) return null;
139
+ let html = readFileSync(templatePath, "utf-8");
140
+ html = html.replace("__TITLE__", data.name);
141
+ const dataJson = JSON.stringify(data);
142
+ html = html.replace(
143
+ "window.FLOW_DATA || { name: 'Flow', frames: [] }",
144
+ `window.FLOW_DATA || ${dataJson}`
145
+ );
146
+ return html;
147
+ }
148
+ function generateInfographicHtml(data, outputPath) {
149
+ try {
150
+ const templatePath = findTemplate();
151
+ if (!templatePath) {
152
+ return { success: false, error: "Infographic template not found" };
153
+ }
154
+ let html = readFileSync(templatePath, "utf-8");
155
+ html = html.replace("__TITLE__", data.name);
156
+ const dataJson = JSON.stringify(data);
157
+ html = html.replace(
158
+ "window.FLOW_DATA || { name: 'Flow', frames: [] }",
159
+ `window.FLOW_DATA || ${dataJson}`
160
+ );
161
+ const outDir = dirname(outputPath);
162
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
163
+ writeFileSync(outputPath, html, "utf-8");
164
+ const stat = statSync(outputPath);
165
+ return {
166
+ success: true,
167
+ outputPath,
168
+ size: stat.size,
169
+ frameCount: data.frames.length
170
+ };
171
+ } catch (error) {
172
+ return {
173
+ success: false,
174
+ error: error instanceof Error ? error.message : String(error)
175
+ };
176
+ }
177
+ }
178
+ export {
179
+ buildInfographicData,
180
+ collectFrameImages,
181
+ generateInfographicHtml,
182
+ generateInfographicHtmlString
183
+ };
@@ -3,7 +3,7 @@ import {
3
3
  getServerPort,
4
4
  startServer,
5
5
  stopServer
6
- } from "./chunk-D4FTLCKM.js";
6
+ } from "./chunk-IVX2OSOJ.js";
7
7
  import "./chunk-34GGYFXX.js";
8
8
  import "./chunk-6GK5K6CS.js";
9
9
  import "./chunk-7R5YNOXE.js";
@@ -2,10 +2,12 @@ import {
2
2
  setupCheckTool,
3
3
  setupInitTool,
4
4
  setupInstallTool,
5
+ setupReplayStatusTool,
5
6
  setupStatusTool,
6
7
  setupTools
7
- } from "./chunk-CUBQRT5L.js";
8
+ } from "./chunk-HFN6BTVO.js";
8
9
  import "./chunk-XKX6NBHF.js";
10
+ import "./chunk-GRU332L4.js";
9
11
  import "./chunk-6EGBXRDK.js";
10
12
  import "./chunk-YYOK2RF7.js";
11
13
  import "./chunk-R5U7XKVJ.js";
@@ -13,6 +15,7 @@ export {
13
15
  setupCheckTool,
14
16
  setupInitTool,
15
17
  setupInstallTool,
18
+ setupReplayStatusTool,
16
19
  setupStatusTool,
17
20
  setupTools
18
21
  };
@@ -107,16 +107,17 @@ import {
107
107
  uiStatusTool,
108
108
  uiTools,
109
109
  videoInfoTool
110
- } from "./chunk-HB3YPWF3.js";
110
+ } from "./chunk-5AISGCS4.js";
111
+ import "./chunk-34GGYFXX.js";
111
112
  import "./chunk-PMCXEA7J.js";
112
113
  import {
113
114
  setupCheckTool,
114
115
  setupInitTool,
116
+ setupReplayStatusTool,
115
117
  setupStatusTool,
116
118
  setupTools
117
- } from "./chunk-CUBQRT5L.js";
119
+ } from "./chunk-HFN6BTVO.js";
118
120
  import "./chunk-XKX6NBHF.js";
119
- import "./chunk-34GGYFXX.js";
120
121
  import "./chunk-7R5YNOXE.js";
121
122
  import "./chunk-3ERJNXYM.js";
122
123
  import "./chunk-LB3RNE3O.js";
@@ -217,6 +218,7 @@ export {
217
218
  projectTools,
218
219
  setupCheckTool,
219
220
  setupInitTool,
221
+ setupReplayStatusTool,
220
222
  setupStatusTool,
221
223
  setupTools,
222
224
  startRecordingTool,
@@ -0,0 +1,177 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>App Flow Map</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ background: #1a1a2e;
11
+ color: #e0e0e0;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
+ min-height: 100vh;
14
+ padding: 32px;
15
+ overflow-x: hidden;
16
+ }
17
+ .map { max-width: 900px; margin: 0 auto; }
18
+ .map-header {
19
+ text-align: center; margin-bottom: 36px;
20
+ opacity: 0; animation: fadeIn 0.6s 0.1s forwards;
21
+ }
22
+ .map-header h1 { font-size: 24px; font-weight: 700; color: #fff; }
23
+ .map-header p { font-size: 13px; color: rgba(255,255,255,0.45); margin-top: 6px; }
24
+ @keyframes fadeIn { to { opacity: 1; } }
25
+ @keyframes slideUp { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } }
26
+
27
+ /* Phase blocks */
28
+ .phase {
29
+ border-radius: 16px;
30
+ padding: 20px;
31
+ margin-bottom: 0;
32
+ opacity: 0;
33
+ animation: slideUp 0.5s forwards;
34
+ position: relative;
35
+ }
36
+ .phase-title { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 4px; }
37
+ .phase-desc { font-size: 12px; opacity: 0.6; margin-bottom: 14px; }
38
+
39
+ /* Phase color themes */
40
+ .phase-0 { background: rgba(34, 197, 94, 0.12); border: 1px solid rgba(34, 197, 94, 0.25); }
41
+ .phase-0 .phase-title { color: #86efac; }
42
+ .phase-1 { background: rgba(99, 102, 241, 0.12); border: 1px solid rgba(99, 102, 241, 0.25); }
43
+ .phase-1 .phase-title { color: #a5b4fc; }
44
+ .phase-2 { background: rgba(234, 179, 8, 0.12); border: 1px solid rgba(234, 179, 8, 0.25); }
45
+ .phase-2 .phase-title { color: #fde047; }
46
+ .phase-3 { background: rgba(236, 72, 153, 0.12); border: 1px solid rgba(236, 72, 153, 0.25); }
47
+ .phase-3 .phase-title { color: #f9a8d4; }
48
+ .phase-4 { background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
49
+ .phase-4 .phase-title { color: #7dd3fc; }
50
+
51
+ /* Screen cards inside phases */
52
+ .phase-screens {
53
+ display: flex; gap: 10px; overflow-x: auto; padding: 4px 0;
54
+ }
55
+ .screen-card {
56
+ flex-shrink: 0;
57
+ background: rgba(255,255,255,0.06);
58
+ border: 1px solid rgba(255,255,255,0.1);
59
+ border-radius: 10px;
60
+ padding: 8px;
61
+ min-width: 130px;
62
+ max-width: 160px;
63
+ transition: transform 0.2s, box-shadow 0.2s;
64
+ }
65
+ .screen-card:hover {
66
+ transform: translateY(-2px);
67
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
68
+ }
69
+ .screen-card-img {
70
+ width: 100%; height: 180px;
71
+ border-radius: 6px; overflow: hidden;
72
+ background: #000; margin-bottom: 6px;
73
+ }
74
+ .screen-card-img img { width: 100%; height: 100%; object-fit: contain; }
75
+ .screen-card-label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.85); }
76
+ .screen-card-detail { font-size: 10px; color: rgba(255,255,255,0.4); margin-top: 2px; }
77
+
78
+ /* Arrow connector between phases */
79
+ .arrow-connector {
80
+ display: flex; align-items: center; justify-content: center;
81
+ height: 40px;
82
+ opacity: 0; animation: fadeIn 0.3s forwards;
83
+ }
84
+ .arrow-connector svg {
85
+ stroke: rgba(255,255,255,0.2); fill: none; stroke-width: 2;
86
+ }
87
+
88
+ /* Insight callout */
89
+ .insight {
90
+ margin-top: 6px; padding: 8px 12px;
91
+ background: rgba(255,255,255,0.04);
92
+ border-left: 3px solid rgba(255,255,255,0.15);
93
+ border-radius: 0 8px 8px 0;
94
+ font-size: 11px; color: rgba(255,255,255,0.55);
95
+ line-height: 1.5;
96
+ }
97
+
98
+ .footer {
99
+ text-align: center; margin-top: 32px;
100
+ font-size: 10px; color: rgba(255,255,255,0.2);
101
+ }
102
+ .model-label {
103
+ position: fixed; bottom: 10px; right: 14px;
104
+ font-size: 9px; color: rgba(255,255,255,0.2);
105
+ background: rgba(0,0,0,0.3);
106
+ padding: 2px 8px; border-radius: 4px;
107
+ }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <div class="map">
112
+ <div class="map-header">
113
+ <h1 id="mapTitle">App Flow Map</h1>
114
+ <p id="mapSubtitle"></p>
115
+ </div>
116
+ <div id="mapContent"></div>
117
+ <div class="footer" id="mapFooter">Generated by DiscoveryLab</div>
118
+ <div class="model-label" id="modelLabel"></div>
119
+ </div>
120
+ <script>
121
+ const DATA = window.__VISUALIZATION_DATA__ || {};
122
+ const phases = DATA.phases || [];
123
+
124
+ if (DATA.title) document.getElementById('mapTitle').textContent = DATA.title;
125
+ if (DATA.subtitle) document.getElementById('mapSubtitle').textContent = DATA.subtitle;
126
+ if (DATA.providerName) document.getElementById('modelLabel').textContent = DATA.providerName;
127
+
128
+ const container = document.getElementById('mapContent');
129
+ let animDelay = 0.3;
130
+
131
+ phases.forEach((phase, phaseIdx) => {
132
+ // Arrow between phases
133
+ if (phaseIdx > 0) {
134
+ const arrow = document.createElement('div');
135
+ arrow.className = 'arrow-connector';
136
+ arrow.style.animationDelay = animDelay + 's';
137
+ arrow.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
138
+ container.appendChild(arrow);
139
+ animDelay += 0.15;
140
+ }
141
+
142
+ const phaseEl = document.createElement('div');
143
+ phaseEl.className = `phase phase-${phaseIdx % 5}`;
144
+ phaseEl.style.animationDelay = animDelay + 's';
145
+
146
+ let html = `
147
+ <div class="phase-title">${phase.title || 'Phase ' + (phaseIdx + 1)}</div>
148
+ <div class="phase-desc">${phase.description || ''}</div>
149
+ `;
150
+
151
+ // Screen cards
152
+ if (phase.screens && phase.screens.length > 0) {
153
+ html += '<div class="phase-screens">';
154
+ phase.screens.forEach(screen => {
155
+ html += `
156
+ <div class="screen-card">
157
+ ${screen.imageUrl ? `<div class="screen-card-img"><img src="${screen.imageUrl}" alt="" loading="lazy"></div>` : ''}
158
+ <div class="screen-card-label">${screen.label || ''}</div>
159
+ ${screen.detail ? `<div class="screen-card-detail">${screen.detail}</div>` : ''}
160
+ </div>
161
+ `;
162
+ });
163
+ html += '</div>';
164
+ }
165
+
166
+ // Insight
167
+ if (phase.insight) {
168
+ html += `<div class="insight">${phase.insight}</div>`;
169
+ }
170
+
171
+ phaseEl.innerHTML = html;
172
+ container.appendChild(phaseEl);
173
+ animDelay += 0.3;
174
+ });
175
+ </script>
176
+ </body>
177
+ </html>
@@ -0,0 +1,150 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Device Showcase</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ background: linear-gradient(135deg, #0c0c1d, #1e1e3f, #0c0c1d);
11
+ color: #fff;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
+ min-height: 100vh;
14
+ overflow: hidden;
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ justify-content: center;
19
+ }
20
+ .header { text-align: center; margin-bottom: 32px; z-index: 10; }
21
+ .header h1 { font-size: 28px; font-weight: 700; }
22
+ .header p { font-size: 13px; color: rgba(255,255,255,0.5); margin-top: 4px; }
23
+ .carousel-wrapper {
24
+ perspective: 1200px;
25
+ width: 100%;
26
+ max-width: 800px;
27
+ height: 500px;
28
+ position: relative;
29
+ }
30
+ .carousel {
31
+ width: 100%;
32
+ height: 100%;
33
+ position: relative;
34
+ transform-style: preserve-3d;
35
+ animation: spin 20s linear infinite;
36
+ animation-play-state: running;
37
+ }
38
+ .carousel:hover { animation-play-state: paused; }
39
+ @keyframes spin { to { transform: rotateY(360deg); } }
40
+ .device {
41
+ position: absolute;
42
+ width: 200px;
43
+ left: 50%;
44
+ top: 50%;
45
+ margin-left: -100px;
46
+ margin-top: -200px;
47
+ transform-style: preserve-3d;
48
+ transition: transform 0.5s, box-shadow 0.5s;
49
+ }
50
+ .device-frame {
51
+ background: #111;
52
+ border-radius: 24px;
53
+ padding: 8px;
54
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1);
55
+ border: 1px solid rgba(255,255,255,0.08);
56
+ }
57
+ .device-screen {
58
+ border-radius: 16px;
59
+ overflow: hidden;
60
+ background: #000;
61
+ }
62
+ .device-screen img {
63
+ width: 100%;
64
+ height: auto;
65
+ display: block;
66
+ }
67
+ .device-label {
68
+ text-align: center;
69
+ font-size: 11px;
70
+ color: rgba(255,255,255,0.5);
71
+ margin-top: 12px;
72
+ }
73
+ .glow {
74
+ position: absolute;
75
+ width: 300px; height: 300px;
76
+ background: radial-gradient(circle, rgba(99,102,241,0.15) 0%, transparent 70%);
77
+ top: 50%; left: 50%;
78
+ transform: translate(-50%, -50%);
79
+ pointer-events: none;
80
+ }
81
+ .footer {
82
+ position: fixed; bottom: 16px;
83
+ font-size: 11px; color: rgba(255,255,255,0.25);
84
+ }
85
+ .nav-dots {
86
+ display: flex; gap: 8px; margin-top: 24px; z-index: 10;
87
+ }
88
+ .nav-dot {
89
+ width: 8px; height: 8px; border-radius: 50%;
90
+ background: rgba(255,255,255,0.2);
91
+ cursor: pointer;
92
+ transition: background 0.3s;
93
+ }
94
+ .nav-dot.active { background: #6366f1; }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <div class="header">
99
+ <h1 id="vizTitle">Device Showcase</h1>
100
+ <p id="vizSubtitle"></p>
101
+ </div>
102
+ <div class="carousel-wrapper">
103
+ <div class="glow"></div>
104
+ <div class="carousel" id="carousel"></div>
105
+ </div>
106
+ <div class="nav-dots" id="navDots"></div>
107
+ <div class="footer" id="vizFooter">Generated by DiscoveryLab</div>
108
+ <script>
109
+ const DATA = window.__VISUALIZATION_DATA__ || {};
110
+ const screens = DATA.screens || [];
111
+
112
+ if (DATA.title) document.getElementById('vizTitle').textContent = DATA.title;
113
+ if (DATA.subtitle) document.getElementById('vizSubtitle').textContent = DATA.subtitle;
114
+
115
+ const carousel = document.getElementById('carousel');
116
+ const dotsContainer = document.getElementById('navDots');
117
+ const n = screens.length || 1;
118
+ const angleStep = 360 / n;
119
+ const radius = Math.max(250, n * 60);
120
+
121
+ screens.forEach((screen, i) => {
122
+ const angle = angleStep * i;
123
+ const device = document.createElement('div');
124
+ device.className = 'device';
125
+ device.style.transform = `rotateY(${angle}deg) translateZ(${radius}px)`;
126
+ device.innerHTML = `
127
+ <div class="device-frame">
128
+ <div class="device-screen">
129
+ <img src="${screen.imageUrl}" alt="Screen ${i + 1}" loading="lazy">
130
+ </div>
131
+ </div>
132
+ <div class="device-label">${screen.label || `Screen ${i + 1}`}</div>
133
+ `;
134
+ carousel.appendChild(device);
135
+
136
+ const dot = document.createElement('div');
137
+ dot.className = `nav-dot ${i === 0 ? 'active' : ''}`;
138
+ dot.addEventListener('click', () => {
139
+ carousel.style.animation = 'none';
140
+ carousel.offsetHeight; // reflow
141
+ carousel.style.transform = `rotateY(${-angle}deg)`;
142
+ carousel.style.transition = 'transform 0.8s ease';
143
+ dotsContainer.querySelectorAll('.nav-dot').forEach(d => d.classList.remove('active'));
144
+ dot.classList.add('active');
145
+ });
146
+ dotsContainer.appendChild(dot);
147
+ });
148
+ </script>
149
+ </body>
150
+ </html>