@veolab/discoverylab 1.6.5 → 1.6.6

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.
@@ -5235,6 +5235,56 @@ function normalizePersistedLocalESVPServerUrl(serverUrl, connectionMode) {
5235
5235
  function resolvePersistedLocalESVPServerUrl(serverUrl, esvp) {
5236
5236
  return normalizePersistedLocalESVPServerUrl(serverUrl, "local") || resolveProjectESVPServerUrl(esvp) || LOCAL_ESVP_SERVER_URL;
5237
5237
  }
5238
+ function cloneJsonRecord(value) {
5239
+ if (!value) return value;
5240
+ try {
5241
+ return JSON.parse(JSON.stringify(value));
5242
+ } catch {
5243
+ return value;
5244
+ }
5245
+ }
5246
+ function detachPortableESVPForExport(esvp, snapshotAttached = false) {
5247
+ if (!esvp) {
5248
+ return { value: null, detached: false };
5249
+ }
5250
+ const cloned = cloneJsonRecord(esvp) || {};
5251
+ const connectionMode = typeof cloned.connectionMode === "string" ? cloned.connectionMode.trim().toLowerCase() : "";
5252
+ const serverUrl = typeof cloned.serverUrl === "string" ? cloned.serverUrl.trim() : "";
5253
+ const shouldDetach = connectionMode === "local" || !serverUrl;
5254
+ if (!shouldDetach) {
5255
+ return { value: cloned, detached: false };
5256
+ }
5257
+ cloned.currentSessionId = null;
5258
+ cloned.serverUrl = null;
5259
+ cloned.detachedForExport = true;
5260
+ cloned.snapshotAttached = snapshotAttached;
5261
+ if (cloned.network && typeof cloned.network === "object") {
5262
+ const network = cloned.network;
5263
+ network.sourceSessionId = null;
5264
+ network.activeCaptureSessionId = null;
5265
+ network.detachedForExport = true;
5266
+ }
5267
+ if (cloned.validation && typeof cloned.validation === "object") {
5268
+ const validation = cloned.validation;
5269
+ validation.sourceSessionId = null;
5270
+ validation.replaySessionId = null;
5271
+ validation.detachedForExport = true;
5272
+ }
5273
+ return { value: cloned, detached: true };
5274
+ }
5275
+ function detachPortableSessionDataForExport(sessionData, detachedEsvp, detached = false) {
5276
+ if (!sessionData) return null;
5277
+ const cloned = cloneJsonRecord(sessionData) || {};
5278
+ if (detached) {
5279
+ cloned.esvp = detachedEsvp;
5280
+ if (cloned.networkCapture && typeof cloned.networkCapture === "object") {
5281
+ const networkCapture = cloned.networkCapture;
5282
+ networkCapture.sessionId = null;
5283
+ networkCapture.detachedForExport = true;
5284
+ }
5285
+ }
5286
+ return cloned;
5287
+ }
5238
5288
  function isESVPReplayValidationSupported(result) {
5239
5289
  if (!result) return true;
5240
5290
  if (result.supported === false) return false;
@@ -5394,6 +5444,14 @@ app.post("/api/export", async (c) => {
5394
5444
  };
5395
5445
  }
5396
5446
  }
5447
+ const detachedEsvpResult = detachPortableESVPForExport(esvp, !!esvpSnapshot);
5448
+ const exportedESVP = detachedEsvpResult.value;
5449
+ const exportedSessionData = detachPortableSessionDataForExport(
5450
+ sessionData,
5451
+ exportedESVP,
5452
+ detachedEsvpResult.detached
5453
+ );
5454
+ const exportedESVPSessionId = detachedEsvpResult.detached ? null : esvpSessionId;
5397
5455
  const packagedProject = {
5398
5456
  ...normalizedProject,
5399
5457
  videoPath: null,
@@ -5433,13 +5491,13 @@ app.post("/api/export", async (c) => {
5433
5491
  recordingFolder: recordingIncluded,
5434
5492
  networkEntries: networkEntries.length,
5435
5493
  exportArtifacts: exportArtifactCount,
5436
- esvpSessionId: esvpSessionId || null
5494
+ esvpSessionId: exportedESVPSessionId || null
5437
5495
  }
5438
5496
  });
5439
5497
  writeExportJson(join6(bundleRoot, "metadata", "project.json"), packagedProject);
5440
5498
  writeExportJson(join6(bundleRoot, "metadata", "exports.json"), exportRecords);
5441
- if (sessionData) {
5442
- writeExportJson(join6(bundleRoot, "metadata", "session.json"), sessionData);
5499
+ if (exportedSessionData) {
5500
+ writeExportJson(join6(bundleRoot, "metadata", "session.json"), exportedSessionData);
5443
5501
  }
5444
5502
  if (rawProject.taskHubLinks) {
5445
5503
  writeExportJson(join6(bundleRoot, "taskhub", "links.json"), normalizedProject.taskHubLinks);
@@ -5465,8 +5523,8 @@ app.post("/api/export", async (c) => {
5465
5523
  if (networkCapture) {
5466
5524
  writeExportJson(join6(bundleRoot, "network", "capture.json"), networkCapture);
5467
5525
  }
5468
- if (esvp) {
5469
- writeExportJson(join6(bundleRoot, "network", "esvp.json"), esvp);
5526
+ if (exportedESVP) {
5527
+ writeExportJson(join6(bundleRoot, "network", "esvp.json"), exportedESVP);
5470
5528
  }
5471
5529
  if (esvpSnapshot) {
5472
5530
  writeExportJson(join6(bundleRoot, "esvp", "snapshot.json"), esvpSnapshot);
@@ -5984,27 +6042,54 @@ app.post("/api/export/infographic", async (c) => {
5984
6042
  const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
5985
6043
  if (!project) return c.json({ error: "Project not found" }, 404);
5986
6044
  const { FRAMES_DIR: fDir, EXPORTS_DIR: eDir, PROJECTS_DIR: pDir } = await import("./db-5ECN3O7F.js");
5987
- const { collectFrameImages, buildInfographicData, generateInfographicHtml } = await import("./infographic-GQAHEOAA.js");
6045
+ const { buildInfographicData, generateInfographicHtml, resolveInfographicFrameInputs } = await import("./infographic-OSDIJM5M.js");
5988
6046
  const dbFrames = await db.select().from(frames).where(eq(frames.projectId, projectId)).orderBy(frames.frameNumber).limit(20);
5989
- let frameFiles;
5990
- let frameOcr;
5991
- if (dbFrames.length > 0) {
5992
- frameFiles = dbFrames.map((f) => f.imagePath);
5993
- frameOcr = dbFrames;
5994
- } else {
5995
- frameFiles = collectFrameImages(join6(fDir, projectId), project.videoPath, pDir, projectId);
5996
- frameOcr = frameFiles.map(() => ({ ocrText: null }));
5997
- }
5998
- if (frameFiles.length === 0) {
5999
- return c.json({ error: "No frames found. Run analyzer first." }, 400);
6047
+ const resolvedFrames = resolveInfographicFrameInputs(
6048
+ dbFrames,
6049
+ join6(fDir, projectId),
6050
+ project.videoPath,
6051
+ pDir,
6052
+ projectId
6053
+ );
6054
+ if (resolvedFrames.frameFiles.length === 0) {
6055
+ return c.json({
6056
+ error: resolvedFrames.candidateCount > 0 ? "No readable frames found for infographic export." : "No frames found. Run analyzer first.",
6057
+ debug: {
6058
+ frameCandidates: resolvedFrames.candidateCount,
6059
+ validFrames: 0,
6060
+ source: resolvedFrames.source,
6061
+ invalidFrames: resolvedFrames.invalidFrames.slice(0, 5)
6062
+ }
6063
+ }, 400);
6000
6064
  }
6001
6065
  const cached = annotationCache.get(projectId);
6002
6066
  const annotations = cached?.steps?.map((s) => ({ label: s }));
6003
- const data = buildInfographicData(project, frameFiles, frameOcr, annotations);
6067
+ const data = buildInfographicData(project, resolvedFrames.frameFiles, resolvedFrames.frameOcr, annotations);
6068
+ if (data.frames.length === 0) {
6069
+ return c.json({
6070
+ error: "Infographic export produced no embeddable frames.",
6071
+ debug: {
6072
+ frameCandidates: resolvedFrames.candidateCount,
6073
+ validFrames: resolvedFrames.frameFiles.length,
6074
+ source: resolvedFrames.source,
6075
+ invalidFrames: resolvedFrames.invalidFrames.slice(0, 5)
6076
+ }
6077
+ }, 400);
6078
+ }
6004
6079
  const slug = (project.marketingTitle || project.name || projectId).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
6005
6080
  const outputPath = join6(eDir, `${slug}-infographic.html`);
6006
6081
  const result = generateInfographicHtml(data, outputPath);
6007
- if (!result.success) return c.json({ error: result.error }, 500);
6082
+ if (!result.success) {
6083
+ return c.json({
6084
+ error: result.error,
6085
+ debug: {
6086
+ frameCandidates: resolvedFrames.candidateCount,
6087
+ validFrames: resolvedFrames.frameFiles.length,
6088
+ source: resolvedFrames.source,
6089
+ invalidFrames: resolvedFrames.invalidFrames.slice(0, 5)
6090
+ }
6091
+ }, 500);
6092
+ }
6008
6093
  if (open) {
6009
6094
  const { exec: exec2 } = await import("child_process");
6010
6095
  exec2(`open "${result.outputPath}"`);
@@ -3789,29 +3789,25 @@ var exportInfographicTool = {
3789
3789
  try {
3790
3790
  const { projects: projectsTable, frames: framesTable, FRAMES_DIR, PROJECTS_DIR: PROJECTS_DIR3 } = await import("./db-5ECN3O7F.js");
3791
3791
  const { eq: eq4 } = await import("drizzle-orm");
3792
- const { collectFrameImages, buildInfographicData, generateInfographicHtml } = await import("./infographic-GQAHEOAA.js");
3792
+ const { buildInfographicData, generateInfographicHtml, resolveInfographicFrameInputs } = await import("./infographic-OSDIJM5M.js");
3793
3793
  const db = getDatabase();
3794
3794
  const [project] = await db.select().from(projectsTable).where(eq4(projectsTable.id, params.projectId)).limit(1);
3795
3795
  if (!project) return createErrorResult(`Project not found: ${params.projectId}`);
3796
3796
  const dbFrames = await db.select().from(framesTable).where(eq4(framesTable.projectId, project.id)).orderBy(framesTable.frameNumber).limit(20);
3797
- let frameFiles;
3798
- let frameOcr;
3799
- if (dbFrames.length > 0) {
3800
- frameFiles = dbFrames.map((f) => f.imagePath);
3801
- frameOcr = dbFrames;
3802
- } else {
3803
- frameFiles = collectFrameImages(
3804
- path5.join(FRAMES_DIR, project.id),
3805
- project.videoPath,
3806
- PROJECTS_DIR3,
3807
- project.id
3808
- );
3809
- frameOcr = frameFiles.map(() => ({ ocrText: null }));
3797
+ const resolvedFrames = resolveInfographicFrameInputs(
3798
+ dbFrames,
3799
+ path5.join(FRAMES_DIR, project.id),
3800
+ project.videoPath,
3801
+ PROJECTS_DIR3,
3802
+ project.id
3803
+ );
3804
+ if (resolvedFrames.frameFiles.length === 0) {
3805
+ return createErrorResult(`No readable frames found for infographic export. Checked ${resolvedFrames.candidateCount} candidate(s) from ${resolvedFrames.source}.`);
3810
3806
  }
3811
- if (frameFiles.length === 0) {
3812
- return createErrorResult("No frames found. Run analyzer first.");
3807
+ const data = buildInfographicData(project, resolvedFrames.frameFiles, resolvedFrames.frameOcr);
3808
+ if (data.frames.length === 0) {
3809
+ return createErrorResult("Infographic export produced no embeddable frames.");
3813
3810
  }
3814
- const data = buildInfographicData(project, frameFiles, frameOcr);
3815
3811
  const slug = (project.marketingTitle || project.name || project.id).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3816
3812
  const outputFilePath = params.outputPath ? path5.join(params.outputPath, `${slug}-infographic.html`) : path5.join(EXPORTS_DIR, `${slug}-infographic.html`);
3817
3813
  const result = generateInfographicHtml(data, outputFilePath);
@@ -7231,21 +7227,21 @@ var knowledgeOpenTool = {
7231
7227
  const { FRAMES_DIR, PROJECTS_DIR: PROJECTS_DIR3 } = await import("./db-5ECN3O7F.js");
7232
7228
  const { join: join12 } = await import("path");
7233
7229
  const dbFrames = await db.select().from(frames).where(eq4(frames.projectId, project.id)).orderBy(frames.frameNumber).limit(15);
7234
- let frameFiles;
7235
- let frameOcr;
7236
- if (dbFrames.length > 0) {
7237
- frameFiles = dbFrames.map((f) => f.imagePath);
7238
- frameOcr = dbFrames;
7239
- } else {
7240
- const { collectFrameImages } = await import("./infographic-GQAHEOAA.js");
7241
- frameFiles = collectFrameImages(join12(FRAMES_DIR, project.id), project.videoPath, PROJECTS_DIR3, project.id);
7242
- frameOcr = frameFiles.map(() => ({ ocrText: null }));
7230
+ const { resolveInfographicFrameInputs, buildInfographicData, generateInfographicHtmlString } = await import("./infographic-OSDIJM5M.js");
7231
+ const resolvedFrames = resolveInfographicFrameInputs(
7232
+ dbFrames,
7233
+ join12(FRAMES_DIR, project.id),
7234
+ project.videoPath,
7235
+ PROJECTS_DIR3,
7236
+ project.id
7237
+ );
7238
+ if (resolvedFrames.frameFiles.length === 0) {
7239
+ return createTextResult(`Project "${project.marketingTitle || project.name}" has no readable frames. Run the analyzer again, then try again.`);
7243
7240
  }
7244
- if (frameFiles.length === 0) {
7245
- return createTextResult(`Project "${project.marketingTitle || project.name}" has no frames. Run the analyzer first, then try again.`);
7241
+ const data = buildInfographicData(project, resolvedFrames.frameFiles, resolvedFrames.frameOcr);
7242
+ if (data.frames.length === 0) {
7243
+ return createErrorResult(`Project "${project.marketingTitle || project.name}" did not produce embeddable infographic frames.`);
7246
7244
  }
7247
- const { buildInfographicData, generateInfographicHtmlString } = await import("./infographic-GQAHEOAA.js");
7248
- const data = buildInfographicData(project, frameFiles, frameOcr);
7249
7245
  const html = generateInfographicHtmlString(data);
7250
7246
  if (!html) {
7251
7247
  return createErrorResult("Failed to generate infographic HTML (template not found)");
@@ -7273,7 +7269,7 @@ var knowledgeOpenTool = {
7273
7269
  structuredContent: {
7274
7270
  projectId: project.id,
7275
7271
  name: project.marketingTitle || project.name,
7276
- frameCount: frameFiles.length,
7272
+ frameCount: resolvedFrames.frameFiles.length,
7277
7273
  platform: project.platform || "unknown"
7278
7274
  },
7279
7275
  _meta: {
package/dist/cli.js CHANGED
@@ -390,7 +390,7 @@ program.command("serve").alias("server").description("Start the DiscoveryLab web
390
390
  console.log(chalk.cyan("\n DiscoveryLab"));
391
391
  console.log(chalk.gray(" AI-powered app testing & evidence generator\n"));
392
392
  try {
393
- const { startServer } = await import("./server-C2NZM2RV.js");
393
+ const { startServer } = await import("./server-GXNAKM4H.js");
394
394
  await startServer(port);
395
395
  console.log(chalk.green(` Server running at http://localhost:${port}`));
396
396
  console.log(chalk.gray(" Press Ctrl+C to stop\n"));
@@ -550,7 +550,7 @@ program.command("mcp").description("Run as MCP server (for Claude Code integrati
550
550
  taskHubTools,
551
551
  esvpTools,
552
552
  knowledgeTools
553
- } = await import("./tools-VXU3JEQP.js");
553
+ } = await import("./tools-6BTUMR3G.js");
554
554
  mcpServer.registerTools([
555
555
  ...uiTools,
556
556
  ...projectTools,
@@ -597,7 +597,7 @@ program.command("export").description("Export project in various formats").argum
597
597
  const { join: pathJoin } = await import("path");
598
598
  const { getDatabase, projects, frames: framesTable, FRAMES_DIR, EXPORTS_DIR, PROJECTS_DIR } = await import("./db-5ECN3O7F.js");
599
599
  const { eq } = await import("drizzle-orm");
600
- const { collectFrameImages, buildInfographicData, generateInfographicHtml } = await import("./infographic-GQAHEOAA.js");
600
+ const { buildInfographicData, generateInfographicHtml, resolveInfographicFrameInputs } = await import("./infographic-OSDIJM5M.js");
601
601
  const db = getDatabase();
602
602
  const allProjects = await db.select().from(projects);
603
603
  const project = allProjects.find((p) => p.id === projectId || p.id.startsWith(projectId) || p.name.toLowerCase().includes(projectId.toLowerCase()));
@@ -611,21 +611,27 @@ program.command("export").description("Export project in various formats").argum
611
611
  }
612
612
  console.log(chalk.green(` \u2714 Found project: ${project.marketingTitle || project.name}`));
613
613
  const dbFrames = await db.select().from(framesTable).where(eq(framesTable.projectId, project.id)).orderBy(framesTable.frameNumber).limit(20);
614
- let frameFiles;
615
- let frameOcr;
616
- if (dbFrames.length > 0) {
617
- frameFiles = dbFrames.map((f) => f.imagePath);
618
- frameOcr = dbFrames;
619
- } else {
620
- frameFiles = collectFrameImages(pathJoin(FRAMES_DIR, project.id), project.videoPath, PROJECTS_DIR, project.id);
621
- frameOcr = frameFiles.map(() => ({ ocrText: null }));
614
+ const resolvedFrames = resolveInfographicFrameInputs(
615
+ dbFrames,
616
+ pathJoin(FRAMES_DIR, project.id),
617
+ project.videoPath,
618
+ PROJECTS_DIR,
619
+ project.id
620
+ );
621
+ console.log(
622
+ chalk.green(
623
+ ` \u2714 ${resolvedFrames.frameFiles.length} readable frames found` + (resolvedFrames.candidateCount > 0 ? ` (from ${resolvedFrames.candidateCount} candidates via ${resolvedFrames.source})` : "")
624
+ )
625
+ );
626
+ if (resolvedFrames.frameFiles.length === 0) {
627
+ console.log(chalk.red(" No readable frames found. Run analyzer first or refresh project screenshots."));
628
+ return;
622
629
  }
623
- console.log(chalk.green(` \u2714 ${frameFiles.length} frames found`));
624
- if (frameFiles.length === 0) {
625
- console.log(chalk.red(" No frames found. Run analyzer first."));
630
+ const data = buildInfographicData(project, resolvedFrames.frameFiles, resolvedFrames.frameOcr);
631
+ if (data.frames.length === 0) {
632
+ console.log(chalk.red(" Export failed: infographic payload did not produce embeddable frames."));
626
633
  return;
627
634
  }
628
- const data = buildInfographicData(project, frameFiles, frameOcr);
629
635
  console.log(chalk.green(` \u2714 ${project.aiSummary ? "AI analysis loaded" : "No analysis (basic labels)"}`));
630
636
  const slug = (project.marketingTitle || project.name || project.id).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
631
637
  const outputPath = opts.output ? pathJoin(opts.output, `${slug}-infographic.html`) : pathJoin(EXPORTS_DIR, `${slug}-infographic.html`);
@@ -6,128 +6,379 @@
6
6
  <title>__TITLE__</title>
7
7
  <style>
8
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
- :root { --accent: #6366f1; --bg: #0f0f17; --bg2: #16161f; --bg3: #1e1e2a; --text: #e4e4e7; --muted: #71717a; --ok: #22c55e; --warn: #eab308; }
10
- body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; min-height: 100vh; overflow-x: hidden; }
11
-
12
- /* Header */
13
- .header { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid rgba(255,255,255,0.05); }
14
- .header-left { display: flex; align-items: center; gap: 12px; }
15
- .header-title { font-size: 16px; font-weight: 700; }
16
- .header-platform { font-size: 10px; padding: 2px 8px; background: rgba(99,102,241,0.12); color: var(--accent); border-radius: 10px; }
17
- .header-date { font-size: 11px; color: var(--muted); }
18
-
19
- /* Step chips */
20
- .chips { display: flex; gap: 4px; padding: 10px 24px; overflow-x: auto; scrollbar-width: none; }
9
+ :root {
10
+ --accent: #6366f1;
11
+ --bg: #0f0f17;
12
+ --bg2: #16161f;
13
+ --bg3: #1e1e2a;
14
+ --text: #e4e4e7;
15
+ --muted: #71717a;
16
+ --ok: #22c55e;
17
+ --warn: #eab308;
18
+ --header-h: 56px;
19
+ --chips-h: 48px;
20
+ --glass-bg: linear-gradient(180deg, rgba(22,22,31,0.82), rgba(22,22,31,0.62));
21
+ --glass-border: rgba(255,255,255,0.09);
22
+ }
23
+ body {
24
+ background: radial-gradient(circle at top, rgba(99,102,241,0.16), transparent 24%), var(--bg);
25
+ color: var(--text);
26
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
27
+ min-height: 100vh;
28
+ overflow-y: auto;
29
+ overflow-x: hidden;
30
+ }
31
+ .viewer-shell {
32
+ display: grid;
33
+ min-height: 100vh;
34
+ grid-template-rows: auto auto 1fr auto;
35
+ }
36
+
37
+ /* ── Header ── */
38
+ .header {
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 12px;
43
+ min-height: var(--header-h);
44
+ padding: 10px 22px;
45
+ border-bottom: 1px solid var(--glass-border);
46
+ position: sticky;
47
+ top: 0;
48
+ z-index: 30;
49
+ background: var(--glass-bg);
50
+ backdrop-filter: blur(20px) saturate(1.35);
51
+ -webkit-backdrop-filter: blur(20px) saturate(1.35);
52
+ }
53
+ .header-left, .header-right {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 10px;
57
+ flex-wrap: wrap;
58
+ }
59
+ .header-title { font-size: 15px; font-weight: 700; }
60
+ .header-platform {
61
+ font-size: 10px;
62
+ padding: 2px 8px;
63
+ background: rgba(99,102,241,0.12);
64
+ color: #aeb2ff;
65
+ border-radius: 10px;
66
+ }
67
+ .header-date, .header-step { font-size: 11px; color: var(--muted); }
68
+
69
+ /* ── Chips ── */
70
+ .chips {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 6px;
74
+ min-height: var(--chips-h);
75
+ padding: 6px 20px 8px;
76
+ overflow-x: auto;
77
+ scrollbar-width: none;
78
+ position: sticky;
79
+ top: var(--header-h);
80
+ z-index: 25;
81
+ background: var(--glass-bg);
82
+ backdrop-filter: blur(20px) saturate(1.35);
83
+ -webkit-backdrop-filter: blur(20px) saturate(1.35);
84
+ border-bottom: 1px solid rgba(255,255,255,0.04);
85
+ }
21
86
  .chips::-webkit-scrollbar { display: none; }
22
- .chip { font-size: 10px; padding: 4px 12px; border-radius: 16px; background: var(--bg2); border: 1px solid rgba(255,255,255,0.06); cursor: pointer; white-space: nowrap; transition: all 0.25s; color: var(--muted); font-weight: 500; }
23
- .chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
24
- .chip:hover:not(.active) { border-color: rgba(255,255,255,0.15); }
25
-
26
- /* Main layout */
27
- .main { display: grid; grid-template-columns: 1fr 300px; height: calc(100vh - 100px); overflow: hidden; }
28
- @media (max-width: 768px) { .main { grid-template-columns: 1fr; } .panel { border-left: none; border-top: 1px solid rgba(255,255,255,0.05); max-height: 200px; } }
29
-
30
- /* Viewer */
31
- .viewer { display: flex; align-items: center; justify-content: center; position: relative; padding: 20px; }
32
- .device-area { position: relative; display: flex; align-items: center; }
33
- .phone { width: 320px; background: #000; border-radius: 28px; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.5); border: 1.5px solid rgba(255,255,255,0.06); position: relative; }
34
- .phone img { width: 100%; display: block; transition: opacity 0.35s ease; }
35
- .phone img.out { opacity: 0; position: absolute; top: 0; left: 0; }
36
- .phone img.in { opacity: 1; position: relative; }
87
+ .chip {
88
+ white-space: nowrap;
89
+ font-size: 10px;
90
+ padding: 5px 12px;
91
+ border-radius: 999px;
92
+ background: var(--bg2);
93
+ border: 1px solid rgba(255,255,255,0.06);
94
+ color: var(--muted);
95
+ cursor: pointer;
96
+ transition: all 120ms ease;
97
+ font-weight: 500;
98
+ flex-shrink: 0;
99
+ }
100
+ .chip.active { background: var(--accent); color: #fff; border-color: transparent; }
37
101
 
38
- /* Side annotations (appear when paused) */
39
- .side-annots { position: absolute; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; opacity: 0; transition: opacity 0.35s; pointer-events: none; width: 160px; }
102
+ /* ── Main Layout ── */
103
+ .main {
104
+ display: grid;
105
+ grid-template-columns: minmax(0, 1fr) 340px;
106
+ min-height: 0;
107
+ align-items: start;
108
+ }
109
+
110
+ /* ── Viewer (device area) ── */
111
+ .viewer {
112
+ display: flex;
113
+ align-items: flex-start;
114
+ justify-content: center;
115
+ position: relative;
116
+ padding: 24px 28px 200px;
117
+ min-height: calc(100vh - var(--header-h) - var(--chips-h));
118
+ }
119
+ .device-wrapper {
120
+ display: flex;
121
+ align-items: flex-start;
122
+ justify-content: center;
123
+ gap: 12px;
124
+ width: 100%;
125
+ max-width: 740px;
126
+ margin: 0 auto;
127
+ }
128
+
129
+ /* ── Side Annotations ── */
130
+ .side-annots {
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 8px;
134
+ width: 160px;
135
+ flex-shrink: 0;
136
+ opacity: 0;
137
+ transition: opacity 200ms ease;
138
+ pointer-events: none;
139
+ padding-top: 20px;
140
+ }
40
141
  .side-annots.show { opacity: 1; pointer-events: auto; }
41
- .side-annots.left { right: calc(100% + 16px); }
42
- .side-annots.right { left: calc(100% + 16px); }
43
- .side-annot { padding: 8px 10px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; position: relative; }
44
- .side-annot::before { content: ''; position: absolute; top: 50%; width: 12px; height: 1px; background: rgba(255,255,255,0.15); }
45
- .side-annots.left .side-annot::before { right: -13px; }
46
- .side-annots.right .side-annot::before { left: -13px; }
142
+ .side-annot {
143
+ padding: 8px 10px;
144
+ background: rgba(255,255,255,0.04);
145
+ border: 1px solid rgba(255,255,255,0.08);
146
+ border-radius: 10px;
147
+ }
47
148
  .side-annot-title { font-size: 10px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 5px; }
48
149
  .side-annot-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
49
- .side-annot-desc { font-size: 9px; color: var(--muted); margin-top: 2px; line-height: 1.4; }
50
-
51
- /* Controls */
52
- .controls { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.6); backdrop-filter: blur(8px); padding: 6px 14px; border-radius: 20px; }
53
- .play-btn { width: 28px; height: 28px; border-radius: 50%; background: transparent; border: 1.5px solid rgba(255,255,255,0.3); color: var(--text); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
54
- .play-btn:hover { border-color: var(--accent); color: var(--accent); }
55
- .dots { display: flex; gap: 5px; }
56
- .dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.15); cursor: pointer; transition: all 0.25s; }
57
- .dot.active { background: var(--accent); width: 16px; border-radius: 3px; }
58
- .status-text { font-size: 9px; color: rgba(255,255,255,0.35); }
59
-
60
- /* Panel */
61
- .panel { padding: 16px; border-left: 1px solid rgba(255,255,255,0.05); overflow-y: auto; background: var(--bg2); display: flex; flex-direction: column; gap: 16px; }
62
- .panel-section { }
63
- .panel-label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); margin-bottom: 6px; }
64
- .step-number { font-size: 11px; color: var(--accent); font-weight: 700; margin-bottom: 2px; }
65
- .step-title { font-size: 14px; font-weight: 600; line-height: 1.3; }
66
- .step-desc { font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 4px; }
67
-
68
- /* Annotation cards in panel */
69
- .annot-cards { display: flex; flex-direction: column; gap: 6px; }
70
- .annot-card { padding: 8px 10px; background: var(--bg3); border-radius: 8px; border-left: 3px solid var(--accent); cursor: pointer; transition: all 0.2s; }
150
+ .side-annot-desc { font-size: 9px; line-height: 1.4; color: var(--muted); margin-top: 2px; }
151
+
152
+ /* ── Device frame ── */
153
+ .device-center {
154
+ display: flex;
155
+ flex-direction: column;
156
+ align-items: center;
157
+ gap: 0;
158
+ flex: 1;
159
+ min-width: 0;
160
+ max-width: 380px;
161
+ }
162
+ .phone {
163
+ width: 100%;
164
+ max-width: 340px;
165
+ background: #000;
166
+ border-radius: 32px;
167
+ overflow: hidden;
168
+ box-shadow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.06);
169
+ position: relative;
170
+ }
171
+ .phone img {
172
+ width: 100%;
173
+ display: block;
174
+ aspect-ratio: 9 / 19.5;
175
+ object-fit: cover;
176
+ object-position: top center;
177
+ background: #000;
178
+ transition: opacity 0.35s ease;
179
+ }
180
+ .phone img.out { opacity: 0; position: absolute; top: 0; left: 0; }
181
+ .phone img.in { opacity: 1; position: relative; }
182
+
183
+ /* ── Floating Play/Pause (Liquid Glass) ── */
184
+ .fab-controls {
185
+ position: fixed;
186
+ left: 16px;
187
+ bottom: 220px;
188
+ z-index: 40;
189
+ display: flex;
190
+ flex-direction: column;
191
+ align-items: center;
192
+ gap: 8px;
193
+ padding: 10px 8px;
194
+ border-radius: 20px;
195
+ background: linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.04));
196
+ border: 1px solid rgba(255,255,255,0.14);
197
+ backdrop-filter: blur(24px) saturate(1.4);
198
+ -webkit-backdrop-filter: blur(24px) saturate(1.4);
199
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.1);
200
+ }
201
+ .fab-btn {
202
+ width: 36px;
203
+ height: 36px;
204
+ border-radius: 50%;
205
+ background: rgba(99,102,241,0.2);
206
+ border: 1.5px solid rgba(255,255,255,0.18);
207
+ color: var(--text);
208
+ cursor: pointer;
209
+ display: flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ transition: all 0.2s;
213
+ }
214
+ .fab-btn:hover { background: var(--accent); border-color: var(--accent); }
215
+ .fab-dots {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 4px;
219
+ align-items: center;
220
+ }
221
+ .fab-dot {
222
+ width: 6px;
223
+ height: 6px;
224
+ border-radius: 50%;
225
+ background: rgba(255,255,255,0.18);
226
+ cursor: pointer;
227
+ transition: all 0.25s;
228
+ }
229
+ .fab-dot.active { background: var(--accent); height: 14px; border-radius: 3px; }
230
+ .fab-status {
231
+ font-size: 8px;
232
+ color: rgba(255,255,255,0.45);
233
+ text-transform: uppercase;
234
+ letter-spacing: 0.06em;
235
+ writing-mode: vertical-lr;
236
+ text-orientation: mixed;
237
+ transform: rotate(180deg);
238
+ }
239
+
240
+ /* ── Right Panel ── */
241
+ .panel {
242
+ padding: 18px 16px 200px;
243
+ border-left: 1px solid rgba(255,255,255,0.05);
244
+ overflow-y: auto;
245
+ background: var(--bg2);
246
+ display: flex;
247
+ flex-direction: column;
248
+ gap: 16px;
249
+ min-height: calc(100vh - var(--header-h) - var(--chips-h));
250
+ position: sticky;
251
+ top: calc(var(--header-h) + var(--chips-h));
252
+ max-height: calc(100vh - var(--header-h) - var(--chips-h));
253
+ }
254
+ .panel-section { display: grid; gap: 6px; }
255
+ .panel-label {
256
+ font-size: 9px;
257
+ font-weight: 700;
258
+ text-transform: uppercase;
259
+ letter-spacing: 0.08em;
260
+ color: var(--muted);
261
+ }
262
+ .project-summary, .step-desc { font-size: 12px; color: var(--muted); line-height: 1.55; }
263
+ .step-number { font-size: 11px; color: #aeb2ff; font-weight: 700; }
264
+ .step-title { font-size: 15px; font-weight: 700; line-height: 1.25; }
265
+ .annot-cards { display: flex; flex-direction: column; gap: 8px; }
266
+ .annot-card {
267
+ padding: 8px 10px;
268
+ background: var(--bg3);
269
+ border-radius: 10px;
270
+ border-left: 3px solid var(--accent);
271
+ transition: all 0.2s;
272
+ }
71
273
  .annot-card:hover { background: rgba(99,102,241,0.08); }
72
274
  .annot-card-title { font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
73
275
  .annot-card-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
74
276
  .annot-card-desc { font-size: 10px; color: var(--muted); margin-top: 2px; line-height: 1.4; }
75
-
76
- .baseline-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; padding: 3px 10px; border-radius: 12px; background: rgba(113,113,122,0.1); color: var(--muted); }
77
- .baseline-badge.ok { background: rgba(34,197,94,0.1); color: var(--ok); }
78
- .baseline-badge.warn { background: rgba(234,179,8,0.1); color: var(--warn); }
79
- .bl-dot { width: 5px; height: 5px; border-radius: 50%; }
80
-
277
+ .baseline-badge {
278
+ display: inline-flex;
279
+ align-items: center;
280
+ gap: 6px;
281
+ width: fit-content;
282
+ font-size: 11px;
283
+ padding: 4px 10px;
284
+ border-radius: 999px;
285
+ background: rgba(113,113,122,0.1);
286
+ color: var(--muted);
287
+ }
288
+ .baseline-badge.ok { background: rgba(34,197,94,0.12); color: var(--ok); }
289
+ .baseline-badge.warn { background: rgba(234,179,8,0.12); color: var(--warn); }
290
+ .bl-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
81
291
  .footer { text-align: center; padding: 6px; font-size: 8px; color: rgba(255,255,255,0.12); }
82
292
 
83
- /* Transitions */
84
- @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
85
- .fade-in { animation: fadeIn 0.3s ease forwards; }
293
+ /* ── Responsive ── */
294
+ @media (max-width: 980px) {
295
+ .main { grid-template-columns: 1fr; }
296
+ .panel {
297
+ border-left: none;
298
+ border-top: 1px solid rgba(255,255,255,0.05);
299
+ position: static;
300
+ max-height: none;
301
+ min-height: auto;
302
+ padding-bottom: 200px;
303
+ }
304
+ .viewer { padding: 20px 16px 40px; }
305
+ .side-annots { width: 130px; }
306
+ .fab-controls { left: 12px; bottom: 200px; }
307
+ }
308
+ @media (max-width: 720px) {
309
+ .viewer { padding: 16px 12px 40px; }
310
+ .phone { max-width: 280px; border-radius: 26px; }
311
+ .side-annots { display: none; }
312
+ .chips { top: var(--header-h); }
313
+ .panel { min-height: auto; padding-bottom: 200px; }
314
+ .device-wrapper { max-width: 320px; }
315
+ .fab-controls { left: 8px; bottom: 180px; padding: 8px 6px; border-radius: 16px; }
316
+ .fab-btn { width: 32px; height: 32px; }
317
+ }
318
+ @media (max-width: 480px) {
319
+ .header { padding: 8px 14px; }
320
+ .header-title { font-size: 13px; }
321
+ .chips { padding: 6px 14px; }
322
+ .phone { max-width: 240px; border-radius: 22px; }
323
+ }
86
324
  </style>
87
325
  </head>
88
326
  <body>
89
- <div class="header">
90
- <div class="header-left">
91
- <div class="header-title" id="title"></div>
92
- <div class="header-platform" id="platform"></div>
93
- </div>
94
- <div class="header-date" id="date"></div>
95
- </div>
96
- <div class="chips" id="chips"></div>
97
- <div class="main">
98
- <div class="viewer">
99
- <div class="device-area" id="deviceArea" style="position: relative;">
100
- <div class="side-annots left" id="annotsLeft"></div>
101
- <div class="phone" id="phone"></div>
102
- <div class="side-annots right" id="annotsRight"></div>
327
+ <div class="viewer-shell">
328
+ <div class="header">
329
+ <div class="header-left">
330
+ <div class="header-title" id="title"></div>
331
+ <div class="header-platform" id="platform"></div>
103
332
  </div>
104
- <div class="controls">
105
- <button class="play-btn" id="playBtn">
106
- <svg id="playIco" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 3 20 12 6 21"/></svg>
107
- <svg id="pauseIco" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
108
- </button>
109
- <div class="dots" id="dots"></div>
110
- <div class="status-text" id="statusText">playing</div>
333
+ <div class="header-right">
334
+ <div class="header-step" id="headerStep"></div>
335
+ <div class="header-date" id="date"></div>
111
336
  </div>
112
337
  </div>
113
- <div class="panel">
114
- <div class="panel-section">
115
- <div class="panel-label">Step</div>
116
- <div class="step-number" id="stepNum"></div>
117
- <div class="step-title" id="stepTitle"></div>
118
- <div class="step-desc" id="stepDesc"></div>
119
- </div>
120
- <div class="panel-section">
121
- <div class="panel-label">Annotations</div>
122
- <div class="annot-cards" id="annotCards"></div>
338
+ <div class="chips" id="chips"></div>
339
+ <div class="main">
340
+ <div class="viewer">
341
+ <div class="device-wrapper">
342
+ <div class="side-annots left" id="annotsLeft"></div>
343
+ <div class="device-center">
344
+ <div class="phone" id="phone"></div>
345
+ </div>
346
+ <div class="side-annots right" id="annotsRight"></div>
347
+ </div>
123
348
  </div>
124
- <div class="panel-section">
125
- <div class="panel-label">Baseline</div>
126
- <div class="baseline-badge" id="blBadge"><div class="bl-dot"></div><span></span></div>
349
+ <div class="panel">
350
+ <div class="panel-section">
351
+ <div class="panel-label">Project</div>
352
+ <div class="project-summary" id="projectSummary"></div>
353
+ </div>
354
+ <div class="panel-section">
355
+ <div class="panel-label">Step</div>
356
+ <div class="step-number" id="stepNum"></div>
357
+ <div class="step-title" id="stepTitle"></div>
358
+ <div class="step-desc" id="stepDesc"></div>
359
+ </div>
360
+ <div class="panel-section">
361
+ <div class="panel-label">Annotations</div>
362
+ <div class="annot-cards" id="annotCards"></div>
363
+ </div>
364
+ <div class="panel-section">
365
+ <div class="panel-label">Baseline</div>
366
+ <div class="baseline-badge" id="blBadge"><div class="bl-dot"></div><span></span></div>
367
+ </div>
127
368
  </div>
128
369
  </div>
370
+ <div class="footer">Generated by DiscoveryLab</div>
371
+ </div>
372
+
373
+ <!-- Floating Liquid Glass Controls -->
374
+ <div class="fab-controls" id="fabControls">
375
+ <button class="fab-btn" id="playBtn" title="Play / Pause">
376
+ <svg id="playIco" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="display:none"><polygon points="7 3 21 12 7 21"/></svg>
377
+ <svg id="pauseIco" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
378
+ </button>
379
+ <div class="fab-dots" id="dots"></div>
380
+ <div class="fab-status" id="statusText">playing</div>
129
381
  </div>
130
- <div class="footer">Generated by DiscoveryLab</div>
131
382
 
132
383
  <script>
133
384
  const D = window.FLOW_DATA || { name: 'Flow', frames: [] };
@@ -137,8 +388,8 @@ let cur = 0, playing = true, timer = null;
137
388
  document.getElementById('title').textContent = D.name || 'App Flow';
138
389
  document.getElementById('platform').textContent = D.platform || '';
139
390
  document.getElementById('date').textContent = D.recorded_at ? new Date(D.recorded_at).toLocaleDateString() : '';
391
+ document.getElementById('projectSummary').textContent = D.overview || 'Captured flow ready for review inside Claude Desktop.';
140
392
 
141
- // Create images
142
393
  const phone = document.getElementById('phone');
143
394
  F.forEach((f, i) => {
144
395
  const img = document.createElement('img');
@@ -148,57 +399,63 @@ F.forEach((f, i) => {
148
399
  phone.appendChild(img);
149
400
  });
150
401
 
151
- // Chips
152
402
  const chips = document.getElementById('chips');
153
403
  F.forEach((f, i) => {
154
404
  const c = document.createElement('div');
155
- c.className = `chip ${i===0?'active':''}`;
156
- c.textContent = `${i+1}. ${f.step_name}`;
405
+ c.className = 'chip' + (i === 0 ? ' active' : '');
406
+ c.textContent = (i + 1) + '. ' + f.step_name;
157
407
  c.onclick = () => { go(i); stop(); };
158
408
  chips.appendChild(c);
159
409
  });
160
410
 
161
- // Dots
162
411
  const dots = document.getElementById('dots');
163
412
  F.forEach((_, i) => {
164
413
  const d = document.createElement('div');
165
- d.className = `dot ${i===0?'active':''}`;
414
+ d.className = 'fab-dot' + (i === 0 ? ' active' : '');
166
415
  d.onclick = () => { go(i); stop(); };
167
416
  dots.appendChild(d);
168
417
  });
169
418
 
170
419
  function go(i) {
171
420
  cur = i;
172
- phone.querySelectorAll('img').forEach((img, j) => { img.className = j===i?'in':'out'; });
173
- chips.querySelectorAll('.chip').forEach((c, j) => c.classList.toggle('active', j===i));
174
- dots.querySelectorAll('.dot').forEach((d, j) => d.classList.toggle('active', j===i));
421
+ phone.querySelectorAll('img').forEach((img, j) => { img.className = j === i ? 'in' : 'out'; });
422
+ chips.querySelectorAll('.chip').forEach((c, j) => c.classList.toggle('active', j === i));
423
+ dots.querySelectorAll('.fab-dot').forEach((d, j) => d.classList.toggle('active', j === i));
424
+ // Auto-scroll active chip into view
425
+ const activeChip = chips.querySelector('.chip.active');
426
+ if (activeChip) activeChip.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
175
427
  updatePanel();
176
428
  updateAnnotations();
177
429
  }
178
430
 
179
431
  function updatePanel() {
180
432
  const f = F[cur]; if (!f) return;
181
- document.getElementById('stepNum').textContent = `${cur+1} of ${F.length}`;
433
+ document.getElementById('stepNum').textContent = (cur + 1) + ' of ' + F.length;
182
434
  document.getElementById('stepTitle').textContent = f.step_name;
183
435
  document.getElementById('stepDesc').textContent = f.description;
436
+ document.getElementById('headerStep').textContent = 'Step ' + (cur + 1) + ' / ' + F.length;
184
437
 
185
- // Annotation cards
186
438
  const cards = document.getElementById('annotCards');
187
439
  cards.innerHTML = '';
188
440
  (f.hotspots || []).forEach(h => {
189
441
  const card = document.createElement('div');
190
- card.className = 'annot-card fade-in';
442
+ card.className = 'annot-card';
191
443
  card.style.borderLeftColor = h.color;
192
- card.innerHTML = `<div class="annot-card-title"><div class="annot-card-dot" style="background:${h.color}"></div>${h.title}</div><div class="annot-card-desc">${h.description}</div>`;
444
+ card.innerHTML = '<div class="annot-card-title"><div class="annot-card-dot" style="background:' + h.color + '"></div>' + escHtml(h.title) + '</div><div class="annot-card-desc">' + escHtml(h.description) + '</div>';
193
445
  cards.appendChild(card);
194
446
  });
195
447
 
196
- // Baseline
197
448
  const bl = document.getElementById('blBadge');
198
449
  const s = f.baseline_status || 'not_validated';
199
- bl.className = `baseline-badge ${s==='ok'?'ok':s==='changed'?'warn':''}`;
200
- bl.querySelector('.bl-dot').style.background = s==='ok'?'var(--ok)':s==='changed'?'var(--warn)':'var(--muted)';
201
- bl.querySelector('span').textContent = s==='ok'?'Validated':s==='changed'?'Changed':'No baseline';
450
+ bl.className = 'baseline-badge ' + (s === 'ok' ? 'ok' : s === 'changed' ? 'warn' : '');
451
+ bl.querySelector('.bl-dot').style.background = s === 'ok' ? 'var(--ok)' : s === 'changed' ? 'var(--warn)' : 'var(--muted)';
452
+ bl.querySelector('span').textContent = s === 'ok' ? 'Validated' : s === 'changed' ? 'Changed' : 'No baseline';
453
+ }
454
+
455
+ function escHtml(s) {
456
+ const el = document.createElement('span');
457
+ el.textContent = s;
458
+ return el.innerHTML;
202
459
  }
203
460
 
204
461
  function updateAnnotations() {
@@ -213,16 +470,21 @@ function updateAnnotations() {
213
470
  left.classList.add('show'); right.classList.add('show');
214
471
 
215
472
  const f = F[cur]; if (!f?.hotspots?.length) return;
216
-
217
473
  f.hotspots.forEach((h, j) => {
218
474
  const container = j % 2 === 0 ? left : right;
219
475
  const el = document.createElement('div');
220
- el.className = 'side-annot fade-in';
221
- el.style.animationDelay = (j * 0.1) + 's';
222
- el.innerHTML = `
223
- <div class="side-annot-title"><div class="side-annot-dot" style="background:${h.color}"></div>${h.label}</div>
224
- ${h.description !== h.label ? `<div class="side-annot-desc">${h.description.slice(0, 60)}</div>` : ''}
225
- `;
476
+ el.className = 'side-annot';
477
+ const titleDiv = document.createElement('div');
478
+ titleDiv.className = 'side-annot-title';
479
+ titleDiv.innerHTML = '<div class="side-annot-dot" style="background:' + h.color + '"></div>';
480
+ titleDiv.appendChild(document.createTextNode(h.label));
481
+ el.appendChild(titleDiv);
482
+ if (h.description !== h.label) {
483
+ const descDiv = document.createElement('div');
484
+ descDiv.className = 'side-annot-desc';
485
+ descDiv.textContent = h.description.slice(0, 72);
486
+ el.appendChild(descDiv);
487
+ }
226
488
  container.appendChild(el);
227
489
  });
228
490
  }
@@ -234,11 +496,14 @@ function play() {
234
496
  document.getElementById('statusText').textContent = 'playing';
235
497
  document.getElementById('annotsLeft').classList.remove('show');
236
498
  document.getElementById('annotsRight').classList.remove('show');
237
- timer = setInterval(() => { cur = (cur+1)%F.length; go(cur); }, 2200);
499
+ clearInterval(timer);
500
+ if (F.length < 2) return;
501
+ timer = setInterval(() => { cur = (cur + 1) % F.length; go(cur); }, 2200);
238
502
  }
239
503
 
240
504
  function stop() {
241
- playing = false; clearInterval(timer);
505
+ playing = false;
506
+ clearInterval(timer);
242
507
  document.getElementById('playIco').style.display = '';
243
508
  document.getElementById('pauseIco').style.display = 'none';
244
509
  document.getElementById('statusText').textContent = 'paused';
@@ -246,7 +511,6 @@ function stop() {
246
511
  }
247
512
 
248
513
  document.getElementById('playBtn').onclick = () => playing ? stop() : play();
249
-
250
514
  updatePanel();
251
515
  play();
252
516
  </script>
package/dist/index.html CHANGED
@@ -22505,10 +22505,13 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
22505
22505
  const sizeKb = ((data.size || 0) / 1024).toFixed(1);
22506
22506
  showToast(`Infographic exported (${sizeKb}KB, ${data.frameCount} frames)`, 'success');
22507
22507
  } else {
22508
- showToast(data.error || 'Export failed', 'error');
22508
+ const debugHint = data.debug && Number.isFinite(data.debug.frameCandidates) && Number.isFinite(data.debug.validFrames)
22509
+ ? ` (${data.debug.validFrames}/${data.debug.frameCandidates} readable frames${data.debug.source ? `, ${data.debug.source}` : ''})`
22510
+ : '';
22511
+ showToast((data.error || 'Export failed') + debugHint, 'error');
22509
22512
  }
22510
22513
  } catch (err) {
22511
- showToast('Export failed', 'error');
22514
+ showToast(err?.message || 'Export failed', 'error');
22512
22515
  }
22513
22516
  }
22514
22517
 
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  startServer,
4
4
  stopServer
5
- } from "./chunk-V6RREMYD.js";
5
+ } from "./chunk-XDUFCPOC.js";
6
6
  import {
7
7
  analyzeTools,
8
8
  canvasTools,
@@ -16,7 +16,7 @@ import {
16
16
  templateTools,
17
17
  testingTools,
18
18
  uiTools
19
- } from "./chunk-Q7Q3A2ZI.js";
19
+ } from "./chunk-YFF3M76J.js";
20
20
  import "./chunk-34GGYFXX.js";
21
21
  import "./chunk-PMCXEA7J.js";
22
22
  import {
@@ -18,18 +18,22 @@ function findTemplate() {
18
18
  return null;
19
19
  }
20
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")}`;
21
+ try {
22
+ if (!existsSync(imagePath) || !statSync(imagePath).isFile()) return "";
23
+ const buffer = readFileSync(imagePath);
24
+ const ext = extname(imagePath).toLowerCase();
25
+ const mimeTypes = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".webp": "image/webp",
30
+ ".gif": "image/gif"
31
+ };
32
+ const mime = mimeTypes[ext] || "image/png";
33
+ return `data:${mime};base64,${buffer.toString("base64")}`;
34
+ } catch {
35
+ return "";
36
+ }
33
37
  }
34
38
  function stripMarkdown(text) {
35
39
  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();
@@ -51,7 +55,92 @@ function collectFrameImages(framesDir, videoPath, projectsDir, projectId) {
51
55
  }
52
56
  return [];
53
57
  }
58
+ function validateFrameInputs(frameFiles, frameOcr) {
59
+ const validFrameFiles = [];
60
+ const validFrameOcr = [];
61
+ const dataUrls = [];
62
+ const invalidFrames = [];
63
+ frameFiles.forEach((filePath, index) => {
64
+ if (!filePath || !existsSync(filePath)) {
65
+ invalidFrames.push({ path: filePath || `frame-${index + 1}`, reason: "missing" });
66
+ return;
67
+ }
68
+ let stats;
69
+ try {
70
+ stats = statSync(filePath);
71
+ } catch {
72
+ invalidFrames.push({ path: filePath, reason: "unreadable" });
73
+ return;
74
+ }
75
+ if (!stats.isFile()) {
76
+ invalidFrames.push({ path: filePath, reason: "not_file" });
77
+ return;
78
+ }
79
+ const dataUrl = imageToBase64(filePath);
80
+ if (!dataUrl) {
81
+ invalidFrames.push({ path: filePath, reason: "unreadable" });
82
+ return;
83
+ }
84
+ validFrameFiles.push(filePath);
85
+ validFrameOcr.push(frameOcr[index] || { ocrText: null });
86
+ dataUrls.push(dataUrl);
87
+ });
88
+ return {
89
+ frameFiles: validFrameFiles,
90
+ frameOcr: validFrameOcr,
91
+ dataUrls,
92
+ invalidFrames
93
+ };
94
+ }
95
+ function resolveInfographicFrameInputs(dbFrames, framesDir, videoPath, projectsDir, projectId) {
96
+ if (dbFrames.length > 0) {
97
+ const dbValidation = validateFrameInputs(
98
+ dbFrames.map((frame) => frame.imagePath),
99
+ dbFrames.map((frame) => ({ ocrText: frame.ocrText ?? null }))
100
+ );
101
+ if (dbValidation.frameFiles.length > 0) {
102
+ return {
103
+ ...dbValidation,
104
+ source: "db",
105
+ candidateCount: dbFrames.length
106
+ };
107
+ }
108
+ const fallbackFrameFiles2 = collectFrameImages(framesDir, videoPath, projectsDir, projectId);
109
+ const fallbackValidation2 = validateFrameInputs(
110
+ fallbackFrameFiles2,
111
+ fallbackFrameFiles2.map(() => ({ ocrText: null }))
112
+ );
113
+ if (fallbackValidation2.frameFiles.length > 0) {
114
+ return {
115
+ ...fallbackValidation2,
116
+ invalidFrames: [...dbValidation.invalidFrames, ...fallbackValidation2.invalidFrames],
117
+ source: "filesystem",
118
+ candidateCount: dbFrames.length + fallbackFrameFiles2.length
119
+ };
120
+ }
121
+ return {
122
+ ...fallbackValidation2,
123
+ invalidFrames: [...dbValidation.invalidFrames, ...fallbackValidation2.invalidFrames],
124
+ source: "none",
125
+ candidateCount: dbFrames.length + fallbackFrameFiles2.length
126
+ };
127
+ }
128
+ const fallbackFrameFiles = collectFrameImages(framesDir, videoPath, projectsDir, projectId);
129
+ const fallbackValidation = validateFrameInputs(
130
+ fallbackFrameFiles,
131
+ fallbackFrameFiles.map(() => ({ ocrText: null }))
132
+ );
133
+ return {
134
+ ...fallbackValidation,
135
+ source: fallbackValidation.frameFiles.length > 0 ? "filesystem" : "none",
136
+ candidateCount: fallbackFrameFiles.length
137
+ };
138
+ }
54
139
  function buildInfographicData(project, frameFiles, frameOcr, annotations) {
140
+ const validatedFrames = validateFrameInputs(frameFiles, frameOcr);
141
+ const usableFrameFiles = validatedFrames.frameFiles;
142
+ const usableFrameOcr = validatedFrames.frameOcr;
143
+ const dataUrls = validatedFrames.dataUrls;
55
144
  let flowSteps = [];
56
145
  let uiElements = [];
57
146
  if (project.aiSummary) {
@@ -64,7 +153,18 @@ function buildInfographicData(project, frameFiles, frameOcr, annotations) {
64
153
  uiElements = (uiMatch[1].match(/^-\s+(.+)$/gm) || []).map((s) => s.replace(/^-\s+/, ""));
65
154
  }
66
155
  }
67
- const elementsPerFrame = Math.max(1, Math.ceil(uiElements.length / Math.max(frameFiles.length, 1)));
156
+ let overview = "";
157
+ if (project.aiSummary) {
158
+ const overviewMatch = project.aiSummary.match(/## (?:App Overview|Page \/ App Overview|Overview|Summary)\n([\s\S]*?)(?=\n##|\n$|$)/);
159
+ if (overviewMatch) {
160
+ const overviewBody = overviewMatch[1].split(/\n\s*\n/).map((chunk) => chunk.trim()).filter(Boolean)[0] || overviewMatch[1];
161
+ overview = stripMarkdown(overviewBody).slice(0, 240);
162
+ }
163
+ }
164
+ if (!overview && project.marketingDescription) {
165
+ overview = stripMarkdown(project.marketingDescription).slice(0, 240);
166
+ }
167
+ const elementsPerFrame = Math.max(1, Math.ceil(uiElements.length / Math.max(usableFrameFiles.length, 1)));
68
168
  const hotspotColors = ["#818CF8", "#34D399", "#F59E0B", "#EC4899", "#06B6D4", "#8B5CF6"];
69
169
  const hotspotPositions = [
70
170
  { x: 50, y: 8 },
@@ -80,10 +180,10 @@ function buildInfographicData(project, frameFiles, frameOcr, annotations) {
80
180
  { x: 15, y: 70 }
81
181
  // bottom left
82
182
  ];
83
- const frames = frameFiles.map((filePath, i) => {
183
+ const frames = usableFrameFiles.map((filePath, i) => {
84
184
  const rawStepName = annotations?.[i]?.label || flowSteps[i] || `Step ${i + 1}`;
85
185
  const stepName = stripMarkdown(rawStepName);
86
- const ocr = frameOcr[i]?.ocrText || "";
186
+ const ocr = usableFrameOcr[i]?.ocrText || "";
87
187
  const rawDesc = flowSteps[i] || ocr.slice(0, 100) || `Screen ${i + 1}`;
88
188
  const description = stripMarkdown(rawDesc);
89
189
  const hotspots = [];
@@ -121,7 +221,7 @@ function buildInfographicData(project, frameFiles, frameOcr, annotations) {
121
221
  id: `frame-${i}`,
122
222
  step_name: stepName,
123
223
  description,
124
- base64: imageToBase64(filePath),
224
+ base64: dataUrls[i] || imageToBase64(filePath),
125
225
  baseline_status: "not_validated",
126
226
  hotspots
127
227
  };
@@ -130,6 +230,7 @@ function buildInfographicData(project, frameFiles, frameOcr, annotations) {
130
230
  name: project.marketingTitle || project.name,
131
231
  platform: project.platform || "unknown",
132
232
  recorded_at: project.createdAt instanceof Date ? project.createdAt.toISOString() : typeof project.createdAt === "string" ? project.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
233
+ overview,
133
234
  frames
134
235
  };
135
236
  }
@@ -179,5 +280,6 @@ export {
179
280
  buildInfographicData,
180
281
  collectFrameImages,
181
282
  generateInfographicHtml,
182
- generateInfographicHtmlString
283
+ generateInfographicHtmlString,
284
+ resolveInfographicFrameInputs
183
285
  };
@@ -3,7 +3,7 @@ import {
3
3
  getServerPort,
4
4
  startServer,
5
5
  stopServer
6
- } from "./chunk-V6RREMYD.js";
6
+ } from "./chunk-XDUFCPOC.js";
7
7
  import "./chunk-34GGYFXX.js";
8
8
  import "./chunk-6GK5K6CS.js";
9
9
  import "./chunk-7R5YNOXE.js";
@@ -107,7 +107,7 @@ import {
107
107
  uiStatusTool,
108
108
  uiTools,
109
109
  videoInfoTool
110
- } from "./chunk-Q7Q3A2ZI.js";
110
+ } from "./chunk-YFF3M76J.js";
111
111
  import "./chunk-34GGYFXX.js";
112
112
  import "./chunk-PMCXEA7J.js";
113
113
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veolab/discoverylab",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
4
4
  "description": "AI-powered app testing & evidence generator - Claude Code Plugin",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -70,7 +70,7 @@
70
70
  "claude-plugin": {
71
71
  "name": "DiscoveryLab",
72
72
  "description": "AI-powered app testing & evidence generator",
73
- "version": "1.6.5",
73
+ "version": "1.6.6",
74
74
  "tools": [
75
75
  "dlab.capture.screen",
76
76
  "dlab.capture.emulator",