@reshotdev/screenshot 0.0.1-beta.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 (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,623 @@
1
+ // record-config.js - Config merging and scenario construction
2
+ const chalk = require("chalk");
3
+ const inquirer = require("inquirer");
4
+ const { readConfig, writeConfig, configExists } = require("./config");
5
+
6
+ /**
7
+ * Parse a path-based visual name into groupPath and key
8
+ * Supports "Folder/Subfolder/Name" syntax for tree-based organization
9
+ * @param {string} pathName - Path-based name (e.g., "Settings/Billing/Invoices Table")
10
+ * @returns {{ groupPath: string|null, key: string, name: string }}
11
+ */
12
+ function parseVisualPath(pathName) {
13
+ if (!pathName || typeof pathName !== "string") {
14
+ return { groupPath: null, key: "", name: "" };
15
+ }
16
+
17
+ const parts = pathName
18
+ .split("/")
19
+ .map((p) => p.trim())
20
+ .filter(Boolean);
21
+
22
+ if (parts.length === 0) {
23
+ return { groupPath: null, key: "", name: "" };
24
+ }
25
+
26
+ // Last part is the visual name, rest is the group path
27
+ const name = parts[parts.length - 1];
28
+ const groupParts = parts.slice(0, -1);
29
+
30
+ // Convert to kebab-case for storage
31
+ const key = parts.map((p) => titleToKey(p)).join("/");
32
+ const groupPath =
33
+ groupParts.length > 0
34
+ ? groupParts.map((p) => titleToKey(p)).join("/")
35
+ : null;
36
+
37
+ return { groupPath, key, name };
38
+ }
39
+
40
+ /**
41
+ * Generate a kebab-case key from a title
42
+ * @param {string} title - Human-readable title (e.g., "Admin Dashboard")
43
+ * @returns {string} - Kebab-case key (e.g., "admin-dashboard")
44
+ */
45
+ function titleToKey(title) {
46
+ if (!title || typeof title !== "string") {
47
+ return "";
48
+ }
49
+
50
+ return title
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9\s-]/g, "") // Remove non-alphanumeric except spaces and hyphens
53
+ .trim()
54
+ .replace(/\s+/g, "-") // Replace spaces with hyphens
55
+ .replace(/-+/g, "-"); // Collapse multiple hyphens
56
+ }
57
+
58
+ function humanizeVisualKey(visualKey) {
59
+ if (!visualKey) {
60
+ return "";
61
+ }
62
+ // Handle path-based keys (e.g., "settings/billing/invoices-table")
63
+ const parts = visualKey.split("/");
64
+ const lastPart = parts[parts.length - 1];
65
+ return lastPart
66
+ .split("-")
67
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
68
+ .join(" ");
69
+ }
70
+
71
+ function getScenarioName(sessionState) {
72
+ if (sessionState.existingScenario?.name) {
73
+ return sessionState.existingScenario.name;
74
+ }
75
+ return humanizeVisualKey(sessionState.visualKey);
76
+ }
77
+
78
+ function readConfigSafe() {
79
+ if (!configExists()) {
80
+ return { scenarios: [] };
81
+ }
82
+
83
+ try {
84
+ const cfg = readConfig();
85
+ if (!cfg || typeof cfg !== "object") {
86
+ return { scenarios: [] };
87
+ }
88
+ return {
89
+ ...cfg,
90
+ scenarios: Array.isArray(cfg.scenarios) ? cfg.scenarios : [],
91
+ };
92
+ } catch (error) {
93
+ console.warn(chalk.yellow("⚠ Existing config is invalid, starting fresh"));
94
+ return { scenarios: [] };
95
+ }
96
+ }
97
+
98
+ function writeConfigSafe(config) {
99
+ writeConfig({
100
+ ...config,
101
+ scenarios: Array.isArray(config.scenarios) ? config.scenarios : [],
102
+ });
103
+ }
104
+
105
+ function findScenarioIndex(config, visualKey) {
106
+ return config.scenarios.findIndex((scenario) => scenario.key === visualKey);
107
+ }
108
+
109
+ /**
110
+ * Show visual selection menu
111
+ * @param {Page} page - Playwright page object
112
+ * @param {string|undefined} title - Optional title for auto-generating the key
113
+ * @returns {Promise<{visualKey, existingScenario}>}
114
+ */
115
+ async function showVisualSelectionMenu(page, title) {
116
+ let existingScenarios = [];
117
+
118
+ if (configExists()) {
119
+ try {
120
+ const config = readConfig();
121
+ existingScenarios = config.scenarios || [];
122
+ } catch (error) {
123
+ // Config exists but is invalid, continue with empty scenarios
124
+ console.warn(
125
+ chalk.yellow("⚠ Existing config is invalid, starting fresh")
126
+ );
127
+ }
128
+ }
129
+
130
+ const choices = [{ name: "Create a new Visual", value: "new" }];
131
+
132
+ if (existingScenarios.length > 0) {
133
+ choices.push({ name: "Edit an existing Visual", value: "edit" });
134
+ }
135
+
136
+ const { mode } = await inquirer.prompt([
137
+ {
138
+ type: "list",
139
+ name: "mode",
140
+ message: "What would you like to do?",
141
+ choices,
142
+ },
143
+ ]);
144
+
145
+ if (mode === "new") {
146
+ // Generate default path from title if provided (supports "Folder/Subfolder/Name" syntax)
147
+ const defaultPath = title || "";
148
+
149
+ console.log(
150
+ chalk.gray(
151
+ '\n💡 Tip: Use path syntax for organization (e.g., "Settings/Billing/Invoices Table")'
152
+ )
153
+ );
154
+ console.log(
155
+ chalk.gray(" This creates a folder structure in the platform UI.\n")
156
+ );
157
+
158
+ const { visualPath } = await inquirer.prompt([
159
+ {
160
+ type: "input",
161
+ name: "visualPath",
162
+ message:
163
+ "Enter a path for this visual (e.g., Settings/Billing/Invoices Table):",
164
+ default: defaultPath,
165
+ validate: (input) => {
166
+ if (!input || input.trim().length === 0) {
167
+ return "Visual path cannot be empty";
168
+ }
169
+ const parsed = parseVisualPath(input);
170
+ if (!parsed.key) {
171
+ return "Invalid path format";
172
+ }
173
+ if (existingScenarios.find((s) => s.key === parsed.key)) {
174
+ return "A visual with this path already exists";
175
+ }
176
+ return true;
177
+ },
178
+ },
179
+ ]);
180
+
181
+ const parsed = parseVisualPath(visualPath);
182
+ return {
183
+ visualKey: parsed.key,
184
+ visualName: parsed.name,
185
+ groupPath: parsed.groupPath,
186
+ existingScenario: null,
187
+ };
188
+ } else {
189
+ // Group existing scenarios by groupPath for better display
190
+ const grouped = {};
191
+ for (const s of existingScenarios) {
192
+ const group = s.groupPath || "(root)";
193
+ if (!grouped[group]) grouped[group] = [];
194
+ grouped[group].push(s);
195
+ }
196
+
197
+ const choices = [];
198
+ for (const [group, scenarios] of Object.entries(grouped).sort()) {
199
+ if (group !== "(root)") {
200
+ choices.push(new inquirer.Separator(`📁 ${group}`));
201
+ }
202
+ for (const s of scenarios) {
203
+ const displayName = s.groupPath
204
+ ? ` ${s.name} (${s.key.split("/").pop()})`
205
+ : `${s.name} (${s.key})`;
206
+ choices.push({ name: displayName, value: s.key });
207
+ }
208
+ }
209
+
210
+ const { visualKey } = await inquirer.prompt([
211
+ {
212
+ type: "list",
213
+ name: "visualKey",
214
+ message: "Select a visual to edit:",
215
+ choices,
216
+ },
217
+ ]);
218
+
219
+ const existingScenario = existingScenarios.find((s) => s.key === visualKey);
220
+ return {
221
+ visualKey,
222
+ visualName: existingScenario?.name,
223
+ groupPath: existingScenario?.groupPath,
224
+ existingScenario,
225
+ };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Optional persona configuration wizard
231
+ * @param {Object} sessionState - Recording session state
232
+ * @returns {Promise<{contexts, matrix}>}
233
+ */
234
+ async function maybeConfigurePersonas(sessionState) {
235
+ const { wantsPersonas } = await inquirer.prompt([
236
+ {
237
+ type: "confirm",
238
+ name: "wantsPersonas",
239
+ message: "Would you like to define persona variations for this visual?",
240
+ default: false,
241
+ },
242
+ ]);
243
+
244
+ if (!wantsPersonas) {
245
+ return { contexts: {}, matrix: undefined };
246
+ }
247
+
248
+ console.log(chalk.cyan("\n📝 Persona Configuration\n"));
249
+
250
+ const contexts = {};
251
+ const personaKeys = [];
252
+
253
+ // Ask for base context
254
+ const { wantsBase } = await inquirer.prompt([
255
+ {
256
+ type: "confirm",
257
+ name: "wantsBase",
258
+ message:
259
+ "Do you want to define a base context (shared by all variations)?",
260
+ default: false,
261
+ },
262
+ ]);
263
+
264
+ if (wantsBase) {
265
+ const { baseContextJson } = await inquirer.prompt([
266
+ {
267
+ type: "input",
268
+ name: "baseContextJson",
269
+ message: 'Enter base context as JSON (e.g., {"env":"staging"}):',
270
+ default: "{}",
271
+ validate: (input) => {
272
+ try {
273
+ JSON.parse(input);
274
+ return true;
275
+ } catch (e) {
276
+ return "Invalid JSON: " + e.message;
277
+ }
278
+ },
279
+ },
280
+ ]);
281
+
282
+ contexts.base = JSON.parse(baseContextJson);
283
+ }
284
+
285
+ // Collect persona contexts
286
+ let addingPersonas = true;
287
+ while (addingPersonas) {
288
+ const { personaKey } = await inquirer.prompt([
289
+ {
290
+ type: "input",
291
+ name: "personaKey",
292
+ message: "Enter persona key (e.g., admin-persona):",
293
+ validate: (input) => {
294
+ if (!input || !input.match(/^[a-z0-9-]+$/)) {
295
+ return "Persona key must contain only lowercase letters, numbers, and hyphens";
296
+ }
297
+ if (personaKeys.includes(input)) {
298
+ return "This persona key already exists";
299
+ }
300
+ return true;
301
+ },
302
+ },
303
+ ]);
304
+
305
+ const { contextJson } = await inquirer.prompt([
306
+ {
307
+ type: "input",
308
+ name: "contextJson",
309
+ message: `Enter context for ${personaKey} as JSON:`,
310
+ default: "{}",
311
+ validate: (input) => {
312
+ try {
313
+ JSON.parse(input);
314
+ return true;
315
+ } catch (e) {
316
+ return "Invalid JSON: " + e.message;
317
+ }
318
+ },
319
+ },
320
+ ]);
321
+
322
+ contexts[personaKey] = JSON.parse(contextJson);
323
+ personaKeys.push(personaKey);
324
+
325
+ const { addMore } = await inquirer.prompt([
326
+ {
327
+ type: "confirm",
328
+ name: "addMore",
329
+ message: "Add another persona?",
330
+ default: false,
331
+ },
332
+ ]);
333
+
334
+ addingPersonas = addMore;
335
+ }
336
+
337
+ // Build simple matrix (single axis with all personas)
338
+ const matrix = personaKeys.length > 0 ? [personaKeys] : undefined;
339
+
340
+ return { contexts, matrix };
341
+ }
342
+
343
+ async function saveScenarioProgress(sessionState, page, options = {}) {
344
+ const {
345
+ finalize = false,
346
+ uiMode = false,
347
+ mergeMode: providedMergeMode,
348
+ } = options;
349
+
350
+ if (finalize) {
351
+ return persistFinalScenario(sessionState, page, {
352
+ uiMode,
353
+ mergeMode: providedMergeMode,
354
+ });
355
+ }
356
+ return persistInProgressScenario(sessionState, page);
357
+ }
358
+
359
+ async function persistInProgressScenario(sessionState, page) {
360
+ const startIndex = sessionState.savedStepCount || 0;
361
+ const newSteps = sessionState.capturedSteps.slice(startIndex);
362
+
363
+ if (newSteps.length === 0) {
364
+ return { wrote: false };
365
+ }
366
+
367
+ const config = readConfigSafe();
368
+ const scenarioName = getScenarioName(sessionState);
369
+ // Use custom scenarioUrl if provided, otherwise use current page URL
370
+ const scenarioUrl = sessionState.scenarioUrl || page.url();
371
+
372
+ let scenarioIndex = findScenarioIndex(config, sessionState.visualKey);
373
+
374
+ if (scenarioIndex === -1) {
375
+ config.scenarios.push({
376
+ name: scenarioName,
377
+ key: sessionState.visualKey,
378
+ url: scenarioUrl,
379
+ steps: [],
380
+ });
381
+ scenarioIndex = config.scenarios.length - 1;
382
+ }
383
+
384
+ const scenario = config.scenarios[scenarioIndex];
385
+ scenario.steps = Array.isArray(scenario.steps) ? scenario.steps : [];
386
+
387
+ // Check if any new step has _persistCropToScenario flag
388
+ // This means the user wants the crop applied to all captures in this scenario
389
+ for (const step of newSteps) {
390
+ if (step._persistCropToScenario && step.crop) {
391
+ // Persist crop to scenario-level output config
392
+ if (!scenario.output) {
393
+ scenario.output = { format: "step-by-step-images" };
394
+ }
395
+ scenario.output.crop = {
396
+ enabled: step.crop.enabled,
397
+ region: step.crop.region,
398
+ scaleMode: step.crop.scaleMode || "none",
399
+ preserveAspectRatio: step.crop.preserveAspectRatio !== false,
400
+ };
401
+ if (step.crop.padding) {
402
+ scenario.output.crop.padding = step.crop.padding;
403
+ }
404
+ // Remove the flag and step-level crop since it's now at scenario level
405
+ delete step._persistCropToScenario;
406
+ delete step.crop;
407
+ console.log(
408
+ chalk.cyan(` → Crop configuration saved to scenario output settings`)
409
+ );
410
+ }
411
+ }
412
+
413
+ scenario.steps.push(...newSteps);
414
+ scenario.url = scenario.url || scenarioUrl;
415
+
416
+ writeConfigSafe(config);
417
+ sessionState.savedStepCount = sessionState.capturedSteps.length;
418
+
419
+ return {
420
+ wrote: true,
421
+ scenarioName,
422
+ scenarioKey: sessionState.visualKey,
423
+ stepsAdded: newSteps.length,
424
+ totalSteps: scenario.steps.length,
425
+ };
426
+ }
427
+
428
+ async function persistFinalScenario(sessionState, page, options = {}) {
429
+ const { uiMode = false, mergeMode: providedMergeMode } = options;
430
+
431
+ const config = readConfigSafe();
432
+ const scenarioKey = sessionState.visualKey;
433
+ const scenarioName = getScenarioName(sessionState);
434
+ // Use custom scenarioUrl if provided, otherwise use current page URL
435
+ const scenarioUrl = sessionState.scenarioUrl || page.url();
436
+
437
+ let scenarioIndex = findScenarioIndex(config, scenarioKey);
438
+ const scenarioExists = scenarioIndex >= 0;
439
+ const existingScenario = scenarioExists
440
+ ? config.scenarios[scenarioIndex]
441
+ : null;
442
+
443
+ const mergePromptNeeded =
444
+ scenarioExists &&
445
+ (sessionState.existingScenario?.steps?.length ||
446
+ existingScenario?.steps?.length);
447
+
448
+ let mergeMode = providedMergeMode || "replace";
449
+
450
+ if (mergePromptNeeded && !uiMode && !providedMergeMode) {
451
+ const { mergeMode: selectedMerge } = await inquirer.prompt([
452
+ {
453
+ type: "list",
454
+ name: "mergeMode",
455
+ message: `A scenario with key '${scenarioKey}' already exists. What would you like to do?`,
456
+ choices: [
457
+ { name: "Replace existing scenario", value: "replace" },
458
+ { name: "Append new steps to the end", value: "append" },
459
+ { name: "Cancel", value: "cancel" },
460
+ ],
461
+ },
462
+ ]);
463
+
464
+ if (selectedMerge === "cancel") {
465
+ console.log(chalk.yellow("Cancelled. Changes not saved."));
466
+ return { wrote: false, cancelled: true };
467
+ }
468
+
469
+ mergeMode = selectedMerge;
470
+ }
471
+
472
+ const baselineSteps =
473
+ mergeMode === "append"
474
+ ? sessionState.existingScenario?.steps || existingScenario?.steps || []
475
+ : [];
476
+
477
+ const mergedSteps =
478
+ mergeMode === "append"
479
+ ? [...baselineSteps, ...sessionState.capturedSteps]
480
+ : [...sessionState.capturedSteps];
481
+
482
+ // In UI mode, skip the persona configuration wizard
483
+ let contexts = {};
484
+ let matrix = undefined;
485
+
486
+ if (!uiMode) {
487
+ const personaConfig = await maybeConfigurePersonas(sessionState);
488
+ contexts = personaConfig.contexts;
489
+ matrix = personaConfig.matrix;
490
+ }
491
+
492
+ // Default output configuration for automatic step-by-step image generation
493
+ const defaultOutput = {
494
+ format: "step-by-step-images",
495
+ highlight: {
496
+ color: "rgba(255, 255, 0, 0.5)",
497
+ style: "box",
498
+ },
499
+ };
500
+
501
+ // Parse groupPath from session state or existing scenario
502
+ const groupPath =
503
+ sessionState.groupPath || existingScenario?.groupPath || null;
504
+
505
+ const finalScenario = {
506
+ name: scenarioName,
507
+ key: scenarioKey,
508
+ url: scenarioUrl,
509
+ // Group path for folder-based organization (Tree + Matrix model)
510
+ ...(groupPath && { groupPath }),
511
+ // Preserve existing output config or use default
512
+ output: existingScenario?.output || defaultOutput,
513
+ steps: mergedSteps,
514
+ };
515
+
516
+ if (matrix) {
517
+ finalScenario.matrix = matrix;
518
+ } else if (mergeMode === "append" && existingScenario?.matrix) {
519
+ finalScenario.matrix = existingScenario.matrix;
520
+ }
521
+
522
+ if (Object.keys(contexts).length > 0) {
523
+ finalScenario.contexts = contexts;
524
+ } else if (mergeMode === "append" && existingScenario?.contexts) {
525
+ finalScenario.contexts = existingScenario.contexts;
526
+ }
527
+
528
+ if (scenarioExists) {
529
+ config.scenarios[scenarioIndex] = finalScenario;
530
+ } else {
531
+ config.scenarios.push(finalScenario);
532
+ }
533
+
534
+ writeConfigSafe(config);
535
+ sessionState.savedStepCount = sessionState.capturedSteps.length;
536
+
537
+ return {
538
+ wrote: true,
539
+ scenarioName,
540
+ scenarioKey,
541
+ stepsTotal: mergedSteps.length,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Finalize scenario and write to config
547
+ * @param {Object} sessionState - Recording session state
548
+ * @param {Page} page - Playwright page object
549
+ * @param {Object} options - Options
550
+ * @param {boolean} options.uiMode - If true, skip inquirer prompts
551
+ * @param {string} options.mergeMode - Merge mode ('replace' or 'append')
552
+ */
553
+ async function finalizeScenarioAndWriteConfig(
554
+ sessionState,
555
+ page,
556
+ options = {}
557
+ ) {
558
+ const { uiMode = false, mergeMode } = options;
559
+
560
+ if (!sessionState.saveOnQuit) {
561
+ console.log(chalk.gray("Exiting without saving..."));
562
+ return;
563
+ }
564
+
565
+ if (sessionState.capturedSteps.length === 0) {
566
+ if (!uiMode) {
567
+ const { confirmDiscard } = await inquirer.prompt([
568
+ {
569
+ type: "confirm",
570
+ name: "confirmDiscard",
571
+ message: "No steps were recorded. Exit without saving?",
572
+ default: true,
573
+ },
574
+ ]);
575
+
576
+ if (confirmDiscard) {
577
+ console.log(chalk.gray("Exited without saving."));
578
+ } else {
579
+ console.log(
580
+ chalk.gray("No changes were saved because no steps were captured.")
581
+ );
582
+ }
583
+ } else {
584
+ console.log(chalk.gray("[UI Mode] No steps were recorded."));
585
+ }
586
+ return { wrote: false, noSteps: true };
587
+ }
588
+
589
+ console.log(chalk.cyan("\n📝 Finalizing scenario...\n"));
590
+
591
+ const result = await saveScenarioProgress(sessionState, page, {
592
+ finalize: true,
593
+ uiMode,
594
+ mergeMode,
595
+ });
596
+
597
+ if (!result?.wrote) {
598
+ return result;
599
+ }
600
+
601
+ console.log(
602
+ chalk.green(
603
+ "\n✔ docsync.config.json has been updated. Please review and commit the changes to your repository.\n"
604
+ )
605
+ );
606
+ console.log(chalk.gray(`Scenario: ${result.scenarioName}`));
607
+ if (typeof result.stepsTotal === "number") {
608
+ console.log(chalk.gray(`Steps captured: ${result.stepsTotal}`));
609
+ }
610
+ console.log(chalk.gray(`URL: ${page.url()}\n`));
611
+
612
+ return result;
613
+ }
614
+
615
+ module.exports = {
616
+ parseVisualPath,
617
+ titleToKey,
618
+ humanizeVisualKey,
619
+ showVisualSelectionMenu,
620
+ maybeConfigurePersonas,
621
+ saveScenarioProgress,
622
+ finalizeScenarioAndWriteConfig,
623
+ };