@spark-apps/quickpeek 1.2.2 → 1.2.4

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.
package/dist/web.mjs CHANGED
@@ -74,8 +74,27 @@ function parsePartial(partial) {
74
74
  function getErrorMessage(err) {
75
75
  return err instanceof Error ? err.message : String(err);
76
76
  }
77
+ var STOCK_PHRASES = [
78
+ [/\bfree to start\b/gi, "free to try"],
79
+ [/\bget started today\b/gi, "start now"],
80
+ [/\bsay goodbye to\b/gi, "no more"],
81
+ [/\bsupercharge\b/gi, "speed up"],
82
+ [/\beffortlessly\b/gi, "easily"],
83
+ [/\bseamlessly\b/gi, ""]
84
+ ];
85
+ function deStock(text) {
86
+ let out = text;
87
+ for (const [re, replacement] of STOCK_PHRASES) out = out.replace(re, replacement);
88
+ return out.replace(/\s{2,}/g, " ").replace(/\s+([,.!?])/g, "$1").trim();
89
+ }
90
+ function stripLongDashes(text) {
91
+ return text.replace(/(\d)\s*[\u2013\u2014]\s*(\d)/g, "$1-$2").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/,\s*,/g, ",").replace(/\s+([,.!?])/g, "$1").replace(/\s{2,}/g, " ").trim();
92
+ }
77
93
  function toTitleCase(text) {
78
- return text.replace(/\w+(?:'\w+)*/g, (word, index) => {
94
+ return deStock(stripLongDashes(text)).replace(/\w+(?:'\w+)*/g, (word, index) => {
95
+ if (/[A-Z]/.test(word.slice(1))) {
96
+ return word;
97
+ }
79
98
  if (index === 0) {
80
99
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
81
100
  }
@@ -85,8 +104,19 @@ function toTitleCase(text) {
85
104
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
86
105
  });
87
106
  }
107
+ async function runPooled(count, size, task) {
108
+ let cursor = 0;
109
+ const worker = async () => {
110
+ while (cursor < count) {
111
+ const index = cursor++;
112
+ await task(index);
113
+ }
114
+ };
115
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(size, count)) }, worker));
116
+ }
88
117
 
89
118
  // src/lib/api-client.ts
119
+ var PLAN_MAX_TOKENS = 1500;
90
120
  var SERVER_BACKOFF_MS = [2e3, 4e3];
91
121
  var SPARK_AI_BASE = "https://sparkbrain.app";
92
122
  var SPARK_AI_CHAT = `${SPARK_AI_BASE}/api/chat`;
@@ -139,7 +169,7 @@ async function getOrRefreshDirectToken() {
139
169
  }
140
170
  }
141
171
  async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier, opts = {}) {
142
- const { onRetry, backoffMs = SERVER_BACKOFF_MS } = opts;
172
+ const { onRetry, backoffMs = SERVER_BACKOFF_MS, maxTokens } = opts;
143
173
  let lastRetriableError = null;
144
174
  const host = hostOf(chatUrl);
145
175
  for (let attempt = 1; attempt <= 3; attempt++) {
@@ -154,7 +184,11 @@ async function executeChat(chatUrl, bearerToken, systemPrompt, userPrompt, tier,
154
184
  messages: [
155
185
  { role: "system", content: systemPrompt },
156
186
  { role: "user", content: userPrompt }
157
- ]
187
+ ],
188
+ // Explicit numeric test, not truthiness: a 0 or negative budget is a
189
+ // caller bug, and silently falling back to the tiny backend default
190
+ // reproduces exactly the failure this option exists to prevent.
191
+ ...typeof maxTokens === "number" && maxTokens > 0 ? { max_tokens: Math.floor(maxTokens) } : {}
158
192
  })
159
193
  });
160
194
  const bodyText = await response.text().catch(() => "");
@@ -367,6 +401,68 @@ function shouldSkipLink(text, href, currentPath) {
367
401
  }
368
402
 
369
403
  // src/lib/crawl.ts
404
+ var SETTLE_POLL_MS = 400;
405
+ var SETTLE_STABLE_POLLS = 2;
406
+ var SETTLE_CAP_MS = 2e4;
407
+ async function waitForDomStable(page, cap = SETTLE_CAP_MS) {
408
+ const deadline = Date.now() + cap;
409
+ let previous = -1;
410
+ let unchanged = 0;
411
+ while (Date.now() < deadline) {
412
+ const count = await page.evaluate(() => document.getElementsByTagName("*").length).catch(() => -1);
413
+ if (count < 0) return;
414
+ if (count === previous) {
415
+ if (++unchanged >= SETTLE_STABLE_POLLS) return;
416
+ } else {
417
+ unchanged = 0;
418
+ previous = count;
419
+ }
420
+ await page.waitForTimeout(SETTLE_POLL_MS);
421
+ }
422
+ }
423
+ var PLACEHOLDER_POLL_MS = 250;
424
+ var PLACEHOLDER_CAP_MS = 8e3;
425
+ var PLACEHOLDER_STATIC_POLLS = 4;
426
+ var PLACEHOLDER_SELECTOR = [
427
+ '[aria-busy="true"]',
428
+ '[role="progressbar"]',
429
+ '[data-loading="true"]',
430
+ '[class*="skeleton" i]',
431
+ '[class*="animate-pulse" i]',
432
+ '[class*="spinner" i]'
433
+ ].join(",");
434
+ async function waitForPlaceholdersGone(page, cap = PLACEHOLDER_CAP_MS) {
435
+ const deadline = Date.now() + cap;
436
+ let previous = -1;
437
+ let unchanged = 0;
438
+ while (Date.now() < deadline) {
439
+ const count = await page.evaluate((selector) => {
440
+ let visible = 0;
441
+ for (const el of Array.from(document.querySelectorAll(selector))) {
442
+ const rect = el.getBoundingClientRect();
443
+ if (rect.width > 0 && rect.height > 0) visible++;
444
+ }
445
+ return visible;
446
+ }, PLACEHOLDER_SELECTOR).catch(() => 0);
447
+ if (count === 0) return;
448
+ if (count === previous) {
449
+ if (++unchanged >= PLACEHOLDER_STATIC_POLLS) return;
450
+ } else {
451
+ unchanged = 0;
452
+ previous = count;
453
+ }
454
+ await page.waitForTimeout(PLACEHOLDER_POLL_MS);
455
+ }
456
+ }
457
+ async function gotoSettled(page, url, timeout = 3e4) {
458
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
459
+ await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {
460
+ });
461
+ await waitForDomStable(page).catch(() => {
462
+ });
463
+ await waitForPlaceholdersGone(page).catch(() => {
464
+ });
465
+ }
370
466
  function idSelector(id) {
371
467
  return /^[A-Za-z_-][\w-]*$/.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
372
468
  }
@@ -387,6 +483,40 @@ function getActionForType(type) {
387
483
  return "type";
388
484
  }
389
485
  }
486
+ async function sitemapRoutes(pageUrl) {
487
+ let origin;
488
+ try {
489
+ origin = new URL(pageUrl).origin;
490
+ } catch {
491
+ return [];
492
+ }
493
+ try {
494
+ const res = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(4e3) });
495
+ if (!res.ok) return [];
496
+ const xml = await res.text();
497
+ const seen = /* @__PURE__ */ new Set();
498
+ const routes = [];
499
+ for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
500
+ const raw = (m[1] ?? "").trim();
501
+ let u;
502
+ try {
503
+ u = new URL(raw);
504
+ } catch {
505
+ continue;
506
+ }
507
+ if (u.origin !== origin) continue;
508
+ if (/\.(txt|xml|json|png|jpg|svg|ico|pdf)$/i.test(u.pathname)) continue;
509
+ const path = u.pathname.replace(/\/+$/, "");
510
+ if (!path || seen.has(u.href)) continue;
511
+ seen.add(u.href);
512
+ routes.push({ text: path.slice(1), href: u.href });
513
+ if (routes.length >= 20) break;
514
+ }
515
+ return routes;
516
+ } catch {
517
+ return [];
518
+ }
519
+ }
390
520
  var noopTask = () => ({ succeed: () => {
391
521
  } });
392
522
  var CRAWL_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -401,155 +531,180 @@ async function crawlPage(url, task = noopTask, storageStatePath, viewport) {
401
531
  }
402
532
  const browserTask = task("Launching browser");
403
533
  const browser = await chromium.launch({ headless: true });
404
- const context = await browser.newContext({
405
- ...storageStatePath ? { storageState: storageStatePath } : {},
406
- // Crawl at the size we will record at, or the measurements below describe
407
- // a page nobody will ever see.
408
- ...viewport ? { viewport } : {}
409
- });
410
- const page = await context.newPage();
411
- browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
412
- const crawlTask = task("Crawling page");
413
- await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
414
- const title = await page.title();
415
- const scrollable = await page.evaluate(
416
- "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
417
- );
418
- const rawElements = await page.evaluate(`
419
- (() => {
420
- const items = [];
534
+ try {
535
+ const context = await browser.newContext({
536
+ ...storageStatePath ? { storageState: storageStatePath } : {},
537
+ // Crawl at the size we will record at, or the measurements below describe
538
+ // a page nobody will ever see.
539
+ ...viewport ? { viewport } : {}
540
+ });
541
+ const page = await context.newPage();
542
+ browserTask.succeed(storageStatePath ? "Browser launched (signed in)" : "Browser launched");
543
+ const crawlTask = task("Crawling page");
544
+ await gotoSettled(page, url, 3e4);
545
+ const title = await page.title();
546
+ const scrollable = await page.evaluate(
547
+ "Math.max(0, document.documentElement.scrollHeight - window.innerHeight)"
548
+ );
549
+ const rawElements = await page.evaluate(`
550
+ (() => {
551
+ const items = [];
421
552
 
422
- // Helper to get parent context
423
- const getCtx = (el) => {
424
- let p = el.parentElement;
425
- while (p && p !== document.body) {
426
- if (p.id) return '#' + p.id;
427
- const cls = Array.from(p.classList).find(c =>
428
- /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
429
- );
430
- if (cls) return '.' + cls;
431
- if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
432
- return p.tagName.toLowerCase();
553
+ // Helper to get parent context
554
+ const getCtx = (el) => {
555
+ let p = el.parentElement;
556
+ while (p && p !== document.body) {
557
+ if (p.id) return '#' + p.id;
558
+ const cls = Array.from(p.classList).find(c =>
559
+ /toolbar|header|footer|sidebar|panel|modal|dialog|nav|menu|controls|actions/i.test(c)
560
+ );
561
+ if (cls) return '.' + cls;
562
+ if (['header', 'footer', 'nav', 'main', 'aside', 'form'].includes(p.tagName.toLowerCase())) {
563
+ return p.tagName.toLowerCase();
564
+ }
565
+ p = p.parentElement;
433
566
  }
434
- p = p.parentElement;
435
- }
436
- return '';
437
- };
567
+ return '';
568
+ };
438
569
 
439
- // Buttons (use textContent to catch hidden elements too)
440
- document.querySelectorAll('button, [role="button"]').forEach(el => {
441
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
442
- const testId = el.getAttribute('data-testid');
443
- const ariaLabel = el.getAttribute('aria-label');
444
- if (!text && !testId && !ariaLabel) return;
445
- items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
446
- });
570
+ // Buttons (use textContent to catch hidden elements too)
571
+ document.querySelectorAll('button, [role="button"]').forEach(el => {
572
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
573
+ const testId = el.getAttribute('data-testid');
574
+ const ariaLabel = el.getAttribute('aria-label');
575
+ if (!text && !testId && !ariaLabel) return;
576
+ items.push({ type: 'button', text: text || ariaLabel || testId, testId, ctx: getCtx(el) });
577
+ });
447
578
 
448
- // Inputs (including file inputs, range sliders, checkboxes, selects)
449
- document.querySelectorAll('input, textarea, select').forEach(el => {
450
- if (el.type === 'hidden') return;
451
- const testId = el.getAttribute('data-testid');
452
- const inputType = el.type || 'text';
579
+ // Inputs (including file inputs, range sliders, checkboxes, selects)
580
+ document.querySelectorAll('input, textarea, select').forEach(el => {
581
+ if (el.type === 'hidden') return;
582
+ const testId = el.getAttribute('data-testid');
583
+ const inputType = el.type || 'text';
453
584
 
454
- // Get label text for any input type
455
- let labelText = '';
456
- if (el.labels?.[0]) {
457
- labelText = el.labels[0].textContent?.trim() || '';
458
- } else if (el.id) {
459
- const label = document.querySelector('label[for="' + el.id + '"]');
460
- if (label) labelText = label.textContent?.trim() || '';
461
- }
585
+ // Get label text for any input type
586
+ let labelText = '';
587
+ if (el.labels?.[0]) {
588
+ labelText = el.labels[0].textContent?.trim() || '';
589
+ } else if (el.id) {
590
+ const label = document.querySelector('label[for="' + el.id + '"]');
591
+ if (label) labelText = label.textContent?.trim() || '';
592
+ }
462
593
 
463
- const text = labelText || el.placeholder || el.name || el.id || '';
594
+ const text = labelText || el.placeholder || el.name || el.id || '';
464
595
 
465
- // Include file, range, and checkbox inputs even without text
466
- const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
467
- if (!text && !testId && !alwaysInclude) return;
596
+ // Include file, range, and checkbox inputs even without text
597
+ const alwaysInclude = ['file', 'range', 'checkbox'].includes(inputType);
598
+ if (!text && !testId && !alwaysInclude) return;
468
599
 
469
- items.push({
470
- type: inputType,
471
- text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
472
- testId,
473
- id: el.id,
474
- name: el.name,
475
- placeholder: el.placeholder,
476
- isFile: inputType === 'file'
600
+ items.push({
601
+ type: inputType,
602
+ text: text || (inputType === 'file' ? 'file upload' : inputType === 'range' ? 'slider' : inputType === 'checkbox' ? 'checkbox' : testId),
603
+ testId,
604
+ id: el.id,
605
+ name: el.name,
606
+ placeholder: el.placeholder,
607
+ isFile: inputType === 'file'
608
+ });
477
609
  });
478
- });
479
610
 
480
- // Links (use textContent to catch hidden elements too)
481
- document.querySelectorAll('a[href]').forEach(el => {
482
- const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
483
- if (text && text.length >= 2) items.push({ type: 'link', text });
484
- });
611
+ // Links (use textContent to catch hidden elements too)
612
+ document.querySelectorAll('a[href]').forEach(el => {
613
+ const text = (el.textContent || el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
614
+ if (!text || text.length < 2) return;
615
+ // Keep the destination, not just the label. Without it the planner
616
+ // can name a page in a caption but has no way to go there, so every
617
+ // line gets forced onto whatever is already on screen.
618
+ let href = '';
619
+ try {
620
+ const u = new URL(el.getAttribute('href') || '', location.href);
621
+ if (u.origin === location.origin) href = u.href;
622
+ } catch { /* a javascript: or mailto: href is not somewhere to go */ }
623
+ items.push({ type: 'link', text, href });
624
+ });
485
625
 
486
- return items.slice(0, 40);
487
- })()
488
- `);
489
- const filteredElements = rawElements.filter((el) => {
490
- if (!el.text) return true;
491
- const text = el.text.toLowerCase().trim();
492
- return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
493
- });
494
- const btnTexts = {};
495
- for (const el of filteredElements) {
496
- if (el.type === "button" && el.text) {
497
- btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
626
+ return items.slice(0, 40);
627
+ })()
628
+ `);
629
+ const filteredElements = rawElements.filter((el) => {
630
+ if (!el.text) return true;
631
+ const text = el.text.toLowerCase().trim();
632
+ return !EXCLUDED_LINK_PATTERNS.some((pattern) => text.includes(pattern));
633
+ });
634
+ const btnTexts = {};
635
+ for (const el of filteredElements) {
636
+ if (el.type === "button" && el.text) {
637
+ btnTexts[el.text] = (btnTexts[el.text] || 0) + 1;
638
+ }
498
639
  }
499
- }
500
- const elements = filteredElements.map((el) => {
501
- let selector;
502
- if (el.testId) {
503
- selector = `[data-testid="${el.testId}"]`;
504
- } else if (el.isFile || el.type === "file") {
505
- selector = 'input[type="file"]';
506
- } else if (el.type === "range") {
507
- selector = el.id ? idSelector(el.id) : 'input[type="range"]';
508
- } else if (el.type === "checkbox") {
509
- selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
510
- } else if (el.type === "button") {
511
- if (btnTexts[el.text] === 1) {
640
+ const elements = filteredElements.map((el) => {
641
+ let selector;
642
+ if (el.testId) {
643
+ selector = `[data-testid="${el.testId}"]`;
644
+ } else if (el.isFile || el.type === "file") {
645
+ selector = 'input[type="file"]';
646
+ } else if (el.type === "range") {
647
+ selector = el.id ? idSelector(el.id) : 'input[type="range"]';
648
+ } else if (el.type === "checkbox") {
649
+ selector = el.id ? idSelector(el.id) : 'input[type="checkbox"]';
650
+ } else if (el.type === "button") {
651
+ if (btnTexts[el.text] === 1) {
652
+ selector = `text="${el.text}"`;
653
+ } else {
654
+ selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
655
+ }
656
+ } else if (el.type === "link") {
512
657
  selector = `text="${el.text}"`;
658
+ } else if (el.id) {
659
+ selector = idSelector(el.id);
660
+ } else if (el.name) {
661
+ selector = `[name="${el.name}"]`;
662
+ } else if (el.placeholder) {
663
+ selector = `[placeholder="${el.placeholder}"]`;
513
664
  } else {
514
- selector = el.ctx ? `${el.ctx} >> text="${el.text}"` : `button:has-text("${el.text}")`;
665
+ selector = `text="${el.text}"`;
515
666
  }
516
- } else if (el.type === "link") {
517
- selector = `text="${el.text}"`;
518
- } else if (el.id) {
519
- selector = idSelector(el.id);
520
- } else if (el.name) {
521
- selector = `[name="${el.name}"]`;
522
- } else if (el.placeholder) {
523
- selector = `[placeholder="${el.placeholder}"]`;
524
- } else {
525
- selector = `text="${el.text}"`;
667
+ let normalizedType;
668
+ if (el.isFile || el.type === "file") {
669
+ normalizedType = "file";
670
+ } else if (el.type === "range") {
671
+ normalizedType = "range";
672
+ } else if (el.type === "checkbox") {
673
+ normalizedType = "checkbox";
674
+ } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
675
+ normalizedType = "select";
676
+ } else if (el.type === "button" || el.type === "link") {
677
+ normalizedType = el.type;
678
+ } else {
679
+ normalizedType = "input";
680
+ }
681
+ const action = getActionForType(normalizedType);
682
+ const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
683
+ return { type: normalizedType, text: el.text, selector, action, target, href: el.href };
684
+ });
685
+ await browser.close();
686
+ for (const route of await sitemapRoutes(url)) {
687
+ if (elements.some((e) => e.href === route.href)) continue;
688
+ elements.push({
689
+ type: "link",
690
+ text: route.text,
691
+ selector: "",
692
+ action: "click",
693
+ target: route.text,
694
+ href: route.href
695
+ });
526
696
  }
527
- let normalizedType;
528
- if (el.isFile || el.type === "file") {
529
- normalizedType = "file";
530
- } else if (el.type === "range") {
531
- normalizedType = "range";
532
- } else if (el.type === "checkbox") {
533
- normalizedType = "checkbox";
534
- } else if (el.type === "select" || el.type === "select-one" || el.type === "select-multiple") {
535
- normalizedType = "select";
536
- } else if (el.type === "button" || el.type === "link") {
537
- normalizedType = el.type;
538
- } else {
539
- normalizedType = "input";
697
+ crawlTask.succeed(`Found ${elements.length} interactive elements`);
698
+ crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
699
+ if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
700
+ const oldest = crawlCache.keys().next().value;
701
+ if (oldest !== void 0) crawlCache.delete(oldest);
540
702
  }
541
- const action = getActionForType(normalizedType);
542
- const target = normalizedType === "file" ? selector : (el.text || "").toLowerCase();
543
- return { type: normalizedType, text: el.text, selector, action, target };
544
- });
545
- await browser.close();
546
- crawlTask.succeed(`Found ${elements.length} interactive elements`);
547
- crawlCache.set(cacheKey, { timestamp: Date.now(), title, elements: [...elements], scrollable });
548
- if (crawlCache.size > CRAWL_CACHE_MAX_ENTRIES) {
549
- const oldest = crawlCache.keys().next().value;
550
- if (oldest !== void 0) crawlCache.delete(oldest);
703
+ return { title, elements, scrollable };
704
+ } finally {
705
+ await browser.close().catch(() => {
706
+ });
551
707
  }
552
- return { title, elements, scrollable };
553
708
  }
554
709
 
555
710
  // src/lib/plan-validation.ts
@@ -747,8 +902,9 @@ function collectPreservedValues(steps) {
747
902
  mediaFile: step.mediaFile,
748
903
  mediaType: step.mediaType
749
904
  });
750
- } else if (step.action === "media") {
905
+ } else if (step.action === "media" || step.action === "image") {
751
906
  mediaSteps.push({
907
+ value: step.value,
752
908
  mediaFile: step.mediaFile,
753
909
  mediaType: step.mediaType,
754
910
  target: step.target
@@ -769,14 +925,15 @@ function restorePreservedValues(step, preserved, indices) {
769
925
  if (original.mediaType !== void 0) step.mediaType = original.mediaType;
770
926
  uploadIdx++;
771
927
  }
772
- if (step.action === "media" && mediaIdx < preserved.mediaSteps.length) {
928
+ if ((step.action === "media" || step.action === "image") && mediaIdx < preserved.mediaSteps.length) {
773
929
  const original = preserved.mediaSteps[mediaIdx];
930
+ if (original.value !== void 0) step.value = original.value;
774
931
  if (original.mediaFile !== void 0) step.mediaFile = original.mediaFile;
775
932
  if (original.mediaType !== void 0) step.mediaType = original.mediaType;
776
933
  if (!step.target && original.target) step.target = original.target;
777
934
  mediaIdx++;
778
935
  }
779
- if (step.action !== "upload" && step.action !== "media" && step.target) {
936
+ if (step.action !== "upload" && step.action !== "media" && step.action !== "image" && step.target) {
780
937
  const original = preserved.otherValues.get(`${step.action}:${step.target}`);
781
938
  if (original) step.value = original;
782
939
  }
@@ -858,9 +1015,13 @@ function planEditorUrl(baseUrl, plan) {
858
1015
  }
859
1016
 
860
1017
  // src/lib/prompts.ts
861
- function buildSystemPrompt(maxSteps) {
1018
+ function buildSystemPrompt(maxSteps, profile) {
862
1019
  let scope;
863
- if (maxSteps <= 5) {
1020
+ if (profile === "wide") {
1021
+ scope = `SCOPE: Educational tutorial (${maxSteps} steps max) - comprehensive, structured coverage of every major feature area, in a logical teaching order`;
1022
+ } else if (profile === "short") {
1023
+ scope = `SCOPE: Quick-paced short (${maxSteps} steps max) - cover every interesting detail with fast cuts and terse captions; running long is fine, the video is compressed afterwards`;
1024
+ } else if (maxSteps <= 5) {
864
1025
  scope = `SCOPE: Minimal demo (${maxSteps} steps max) - ONE complete user journey only`;
865
1026
  } else if (maxSteps <= 8) {
866
1027
  scope = `SCOPE: Standard demo (${maxSteps} steps max) - One journey with 1-2 options`;
@@ -872,9 +1033,47 @@ function buildSystemPrompt(maxSteps) {
872
1033
  ${scope}
873
1034
 
874
1035
  CORE FLOW (always follow this structure):
875
- 1. wait - Intro (what does this app do?)
1036
+ 1. wait - the hook, over the app's own card (see OPENING AND CLOSING)
876
1037
  2. Actions - Complete ONE user journey (upload\u2192process\u2192result)
877
- 3. wait - Outro (what was accomplished?)
1038
+ 3. Stop. The closing card is appended for you - do not write one
1039
+
1040
+ NARRATION STYLE:
1041
+ \u2022 Professional and direct, like a good tutorial - never condescending, never explaining as if to a child
1042
+ \u2022 NEVER mention step numbers or sequencing ("step one", "first step", "in this step") - just say what is happening
1043
+ \u2022 NEVER build the demo out of marketing surfaces. The landing page and the PRICING page are not the product: a viewer wants to see the thing working, not read plans and prices. If the app is behind a sign-in, demo the signed-in app; if that is impossible, demo its functional public pages (tools, docs, editors), not its sales pages
1044
+ \u2022 A marketing/landing page earns ONE brief opening caption of about a SECOND - a title card, nothing more. Never scroll it, never hover or highlight anything on it, never describe its sections. Optionally return to it for the closing call to action. The demo lives in the app itself, behind the login when a session exists. Never return to the landing page mid-demo to describe a feature the app can show you doing
1045
+ \u2022 NARRATE ONLY WHAT IS ON SCREEN: every caption must describe what the viewer can see at that moment. To talk about a specific component, make it the step's target (hover it, click it, or scroll with that target) so the recorder brings it into frame first
1046
+ \u2022 A blind scroll (no target) lands wherever it lands - use it only for "there is more below", never to reveal something the caption names
1047
+ \u2022 Never combine scrolling with pointing: a step either moves the page or holds still on a component, so the highlight is never dragged across the screen mid-scroll
1048
+ \u2022 A step that starts something asynchronous (processing, generating, analyzing) must WAIT for the result on screen before moving on: give the wait step a "duration" in seconds long enough to show the outcome
1049
+ \u2022 Caption length drives step length - keep captions tight so the video moves
1050
+
1051
+ OPENING AND CLOSING (a fixed template - both cards are built for you):
1052
+ \u2022 Step 1 is ALWAYS a bare { "action": "wait" } with no target. Its caption is the HOOK, and the recorder puts the app's own OG card behind it
1053
+ \u2022 The hook names the VIEWER'S problem in the viewer's words - the cost of not having this app, in one line someone would recognise as their own situation. It is the reason to keep watching
1054
+ \u2022 NEVER open the hook with the product: no "This is X", no "Meet X", no "X helps you..." and no feature. The problem comes first
1055
+ \u2022 Then, in that SAME caption, say the app's address once - "<problem>. <domain> fixes that." A viewer who leaves halfway has to have heard where to go, and the closing card is too late for them. The opening card is both spoken AND captioned, so write the hook to read as well as it sounds: short words, no spelled-out punctuation, and the domain written the way it is typed
1056
+ \u2022 The closing card is appended for you: the app's name, tagline, domain and any live offer, narrated. Never write a sign-off step and never end back on the landing page. Apart from the hook, keep the domain and any discount code out of captions - the card carries both, and repeating them mid-demo is the most common way these scripts read as padding
1057
+
1058
+ HOW IT SHOULD SOUND (this is read aloud, not printed on a slide):
1059
+ \u2022 Write the way a person talks about something they actually like. Warm, quick, a bit of a grin. Never the voice of a landing page
1060
+ \u2022 BANNED, because they are what marketing copy sounds like when nobody meant it: "free to start", "get started today", "unlock", "empower", "seamlessly", "effortlessly", "revolutionise", "supercharge", "take your X to the next level", "say goodbye to". If a line could sit on any product's homepage unchanged, it is not about this product
1061
+ \u2022 The free tier is "try it free", or just "free". Never "free to start", which reads as a pricing table talking
1062
+ \u2022 Short sentences beat long ones out loud. A comma is a breath; a full stop is a beat. Read it back before shipping it
1063
+ \u2022 Say the thing, do not build up to it. No "in this video we will", no "let's take a look at"
1064
+
1065
+ WHAT TO COVER (a tour of tabs is a menu, not a demo):
1066
+ \u2022 Lead with the outcome the app produces, not the screens it has
1067
+ \u2022 Say where that outcome ENDS UP when the app puts it somewhere - search results, AI assistant answers, directories, an app store
1068
+ \u2022 Name any integration that removes work rather than adding a screen - an MCP server, a browser extension that fills the forms, an API, a CLI. An app that can be driven from an assistant is a headline feature, not a footnote
1069
+ \u2022 Do NOT write a pricing step. When the app has a free tier the closing card says so for you, narrated, at the end where it belongs. Never navigate to the pricing page for it
1070
+ \u2022 Cover what surrounds the product as well as the product: a community, a network its users list each other on, a support channel, anything published on the user's behalf. These are reasons to sign up and they are never on screen by themselves
1071
+ \u2022 Say what a feature DOES, never what it is implemented as. Filenames, formats and protocols are not benefits: "so AI assistants can read your product" lands, "it writes llms.txt and robots.txt" is a changelog line read aloud. Same for sitemap.xml, JSON-LD, schema markup, API endpoints and anything ending in .txt, .xml or .json. If a viewer would have to already know what it is, it is not a selling point
1072
+ \u2022 Prefer one feature shown working over three named in passing
1073
+ \u2022 When a step types into a URL or domain field, type a REAL domain, never example.com or example.org. A demo that audits "example.com" on camera shows the tool working on nothing. Use a real site the viewer might recognise, or the demo subject's own domain if nothing better is on the page
1074
+ \u2022 A plan that is mostly bare wait steps is a failed plan. One real run came back 22 waits out of 26: a slideshow of one static screen, narrating pages it never visited. If a caption names a page, NAVIGATE to it; if it names a control, CLICK it. Use wait only for the opening and closing cards
1075
+ \u2022 NEVER narrate a screen you are not on. If a line describes a page the demo has not reached, navigate to it first. A caption saying "start with a free roast" over the dashboard is worse than cutting the line: it makes the whole video look faked
1076
+ \u2022 Cover everything worth covering and let it run long. Length is fixed afterwards by playing the demo faster, never by dropping a feature - a plan trimmed to hit a target is the one mistake the compressor cannot undo
878
1077
 
879
1078
  TARGET SYNTAX (simplified):
880
1079
  \u2022 Buttons/Links: Use plain text like "extract frame", "download" (case insensitive, partial match OK)
@@ -897,9 +1096,12 @@ FORBIDDEN:
897
1096
 
898
1097
  ACTION FORMATS (STRICT - follow exactly):
899
1098
  \u2022 click: { "action": "click", "target": "extract frame" } - use simplified button text
1099
+ \u2022 hover: { "action": "hover", "target": "pricing card" } - point the cursor at a feature while the caption explains it, without clicking
900
1100
  \u2022 type: { "action": "type", "target": "input[name=\\"field\\"]", "value": "text to type" }
901
1101
  \u2022 upload: { "action": "upload", "target": "input[type=\\"file\\"]", "value": "[file]" }
1102
+ \u2022 navigate: { "action": "navigate", "target": "https://example.com/roast" } - GO TO ANOTHER PAGE. Use it whenever the caption describes something that is not on the current screen. Any path on the SAME ORIGIN as the demo URL is allowed, not only the NAVIGATE targets listed below: an app's own routes are frequently reached by client-side buttons rather than links, so the list is a hint, not the limit. Never navigate off-origin.
902
1103
  \u2022 wait: { "action": "wait" } - for intro/outro captions
1104
+ \u2022 scroll to a component: { "action": "scroll", "target": "seo files", "value": "down" } - brings that element into frame; PREFER this over a blind scroll whenever the caption names something
903
1105
  \u2022 scroll: { "action": "scroll", "value": "-80" } - negative=zoom out, positive=zoom in
904
1106
  \u2022 drag: { "action": "drag", "target": "input[type=\\"range\\"]", "value": "50" }
905
1107
  \u2022 select: { "action": "select", "target": "select[name=\\"..\\"]", "value": "option" }
@@ -924,6 +1126,17 @@ Purpose: ${description}` : "";
924
1126
  const selectables = elements.filter((e) => e.action === "select");
925
1127
  const checkables = elements.filter((e) => e.action === "check");
926
1128
  const elementDict = [];
1129
+ const navigables = elements.filter((e) => e.href);
1130
+ const seenHref = /* @__PURE__ */ new Set();
1131
+ const linkTargets = navigables.filter((e) => {
1132
+ if (!e.href || seenHref.has(e.href)) return false;
1133
+ seenHref.add(e.href);
1134
+ return true;
1135
+ });
1136
+ if (linkTargets.length > 0) {
1137
+ elementDict.push(`NAVIGATE targets (links found on the page; same-origin paths not listed here are also allowed):
1138
+ ${linkTargets.map((e) => `\u2022 "${e.text}" -> ${e.href}`).join("\n")}`);
1139
+ }
927
1140
  if (clickables.length > 0) {
928
1141
  elementDict.push(`CLICK targets (use simplified text):
929
1142
  ${clickables.map((e) => `\u2022 "${e.target || e.text.toLowerCase()}"`).join("\n")}`);
@@ -994,26 +1207,23 @@ var APP_ID = process.env.SPARK_APP_ID ?? "quickpeek";
994
1207
  var OWNER_SLUG = process.env.SPARK_OWNER_SLUG ?? "spark";
995
1208
  var BASE = "https://sparkpay.dev";
996
1209
  var TIERS = {
997
- price_1TADnPEf9opkuWid9NtqOYON: "pro",
998
- price_1TADnPEf9opkuWidgbvvX9ma: "pro",
1210
+ // Essentials
1211
+ price_1TCREmEf9opkuWidvhuqzqDM: "pro",
1212
+ // live monthly
1213
+ price_1TCREmEf9opkuWidacf1tiC7: "pro",
1214
+ // live yearly
999
1215
  price_1TADm7Ef9opkuWid3I9tquIB: "pro",
1216
+ // test monthly
1000
1217
  price_1TADm8Ef9opkuWid1RSA2wCa: "pro",
1001
- price_1TCREmEf9opkuWidvhuqzqDM: "pro",
1002
- price_1TCREmEf9opkuWidacf1tiC7: "pro"
1003
- };
1004
- var RANK = {
1005
- free: 0,
1006
- pro: 1
1007
- };
1008
- var FEATURES = {
1009
- free: [],
1010
- pro: [
1011
- "Everything in Free",
1012
- "No watermark",
1013
- "Full video composition (voiceover + captions + music + transitions)",
1014
- "Unlimited videos",
1015
- "Priority support"
1016
- ]
1218
+ // test yearly
1219
+ // Elite. These were missing entirely, so an Elite subscriber fell through
1220
+ // the `?? 'pro'` fallback below and got Essentials limits for $29.
1221
+ price_1TrHz8Ef9opkuWid6lh2opD8: "elite",
1222
+ // live monthly
1223
+ price_1TrHz9Ef9opkuWid1qS26iMu: "elite",
1224
+ // live yearly
1225
+ price_1TGPCREf9opkuWidR0iJeJNe: "elite"
1226
+ // test monthly
1017
1227
  };
1018
1228
  async function _fetch(email, init) {
1019
1229
  const r = await fetch(
@@ -1038,7 +1248,6 @@ function getTier(d) {
1038
1248
  return TIERS[d.subscription.price_id] ?? "pro";
1039
1249
  }
1040
1250
  var isPaid = (d) => getTier(d) !== "free";
1041
- var hasAccess = (d, min) => (RANK[getTier(d)] ?? 0) >= (RANK[min] ?? 0);
1042
1251
  var hasTrialRemaining = (d) => isPaid(d) || !!d.trial && !d.trial.is_expired && d.trial.uses_remaining > 0;
1043
1252
  var showPaywall = (d) => !isPaid(d) && !hasTrialRemaining(d);
1044
1253
  var isCanceling = (d) => !!d.subscription && d.subscription.cancel_at_period_end && d.subscription.status !== "canceled";
@@ -1064,22 +1273,43 @@ function pricingUrl(opts) {
1064
1273
  }
1065
1274
 
1066
1275
  // src/lib/voices.ts
1067
- import { WIDE_VOICES } from "@spark-apps/video-kit";
1276
+ import { MALE_VOICES, MULTILINGUAL_VOICES, resolveVoice, WIDE_VOICES } from "@spark-apps/video-kit";
1277
+ import { MALE_VOICES as MALE_VOICES2, MULTILINGUAL_VOICES as MULTILINGUAL_VOICES2, WIDE_VOICES as WIDE_VOICES2 } from "@spark-apps/video-kit";
1278
+ var INTERNATIONAL_VOICE = "international";
1279
+ function internationalVoiceFor(lang, gender = "female") {
1280
+ if (!hasVoice(lang)) return null;
1281
+ const raw = lang.trim().toLowerCase();
1282
+ const multilingual = MULTILINGUAL_VOICES[raw] ?? MULTILINGUAL_VOICES[raw.slice(0, 2)];
1283
+ if (gender === "male") return voiceFor(lang, gender);
1284
+ return multilingual ?? voiceFor(lang, gender);
1285
+ }
1286
+ function hasVoice(lang) {
1287
+ const raw = lang.trim().toLowerCase();
1288
+ return Boolean(WIDE_VOICES[raw] ?? WIDE_VOICES[raw.slice(0, 2)]);
1289
+ }
1290
+ function voiceFor(lang, gender = "female") {
1291
+ if (!hasVoice(lang)) return null;
1292
+ return resolveVoice(lang, { gender, table: WIDE_VOICES, maleTable: MALE_VOICES });
1293
+ }
1068
1294
  export {
1069
1295
  ACTIONS_REQUIRING_TARGET,
1070
1296
  EXCLUDED_LINK_PATTERNS,
1071
- FEATURES,
1072
1297
  FIX_PLAN_SYSTEM_PROMPT,
1073
1298
  INTERACTIVE_SELECTORS,
1299
+ INTERNATIONAL_VOICE,
1074
1300
  INVALID_TARGETS,
1075
- WIDE_VOICES as LANGUAGE_VOICES,
1301
+ MALE_VOICES2 as MALE_VOICES,
1076
1302
  MAX_HANDOFF_URL_CHARS,
1303
+ MULTILINGUAL_VOICES2 as MULTILINGUAL_VOICES,
1304
+ PLAN_MAX_TOKENS,
1077
1305
  REFINE_PLAN_SYSTEM_PROMPT,
1306
+ WIDE_VOICES2 as WIDE_VOICES,
1078
1307
  buildSystemPrompt,
1079
1308
  buildUserPrompt,
1080
1309
  callAI,
1081
1310
  collectPreservedValues,
1082
1311
  crawlPage,
1312
+ deStock,
1083
1313
  decodePlan,
1084
1314
  encodePlan,
1085
1315
  filterValidSteps,
@@ -1089,9 +1319,10 @@ export {
1089
1319
  getStatusCached,
1090
1320
  getTier,
1091
1321
  getTierByEmail,
1092
- hasAccess,
1093
1322
  hasTrialRemaining,
1323
+ hasVoice,
1094
1324
  hexToAss as hexToASS,
1325
+ internationalVoiceFor,
1095
1326
  isCanceling,
1096
1327
  isExcludedLink,
1097
1328
  isInvalidTarget,
@@ -1106,10 +1337,13 @@ export {
1106
1337
  pricingUrl,
1107
1338
  restorePreservedValues,
1108
1339
  rgbaToHex,
1340
+ runPooled,
1109
1341
  serializePlan,
1110
1342
  shouldSkipLink,
1111
1343
  showPaywall,
1344
+ stripLongDashes,
1112
1345
  toTitleCase,
1113
1346
  validateStep,
1347
+ voiceFor,
1114
1348
  windowsDrivePathToWsl as windowsPathToWSL
1115
1349
  };