qapture2 0.7.0 → 0.7.2

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.
@@ -1511,6 +1511,128 @@ function matchRouteToSteps(journey, route) {
1511
1511
  return [...exact, ...param];
1512
1512
  }
1513
1513
 
1514
+ // src/lib/fallbackJourney.ts
1515
+ var ANY_PAGE = "*";
1516
+ var FALLBACK_JOURNEY = [
1517
+ {
1518
+ id: "first-run",
1519
+ role: { en: "First look", ar: "\u0627\u0644\u0646\u0638\u0631\u0629 \u0627\u0644\u0623\u0648\u0644\u0649" },
1520
+ steps: [
1521
+ {
1522
+ path: ANY_PAGE,
1523
+ what: { en: "Open the app fresh and let it finish loading", ar: "\u0627\u0641\u062A\u062D \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0645\u0646 \u062C\u062F\u064A\u062F \u0648\u0627\u062A\u0631\u0643\u0647 \u064A\u0643\u0645\u0644 \u0627\u0644\u062A\u062D\u0645\u064A\u0644" },
1524
+ expect: {
1525
+ en: "Content appears, nothing flashes or jumps, no error in the corner",
1526
+ ar: "\u064A\u0638\u0647\u0631 \u0627\u0644\u0645\u062D\u062A\u0648\u0649 \u062F\u0648\u0646 \u0648\u0645\u064A\u0636 \u0623\u0648 \u0642\u0641\u0632\u060C \u0648\u0628\u062F\u0648\u0646 \u0631\u0633\u0627\u0644\u0629 \u062E\u0637\u0623"
1527
+ },
1528
+ risk: "red"
1529
+ },
1530
+ {
1531
+ path: ANY_PAGE,
1532
+ what: { en: "Read the first screen as a new user would", ar: "\u0627\u0642\u0631\u0623 \u0627\u0644\u0634\u0627\u0634\u0629 \u0627\u0644\u0623\u0648\u0644\u0649 \u0628\u0639\u064A\u0646 \u0645\u0633\u062A\u062E\u062F\u0645 \u062C\u062F\u064A\u062F" },
1533
+ expect: {
1534
+ en: "It is obvious what this is and what to do next",
1535
+ ar: "\u0648\u0627\u0636\u062D \u0645\u0627 \u0647\u0630\u0627 \u0648\u0645\u0627 \u0627\u0644\u062E\u0637\u0648\u0629 \u0627\u0644\u062A\u0627\u0644\u064A\u0629"
1536
+ },
1537
+ risk: "amber"
1538
+ }
1539
+ ]
1540
+ },
1541
+ {
1542
+ id: "moving-around",
1543
+ role: { en: "Moving around", ar: "\u0627\u0644\u062A\u0646\u0642\u0644" },
1544
+ steps: [
1545
+ {
1546
+ path: ANY_PAGE,
1547
+ what: { en: "Visit every item in the main menu", ar: "\u0627\u0641\u062A\u062D \u0643\u0644 \u0639\u0646\u0635\u0631 \u0641\u064A \u0627\u0644\u0642\u0627\u0626\u0645\u0629 \u0627\u0644\u0631\u0626\u064A\u0633\u064A\u0629" },
1548
+ expect: { en: "Each one opens the page it names \u2014 no blank screens", ar: "\u0643\u0644 \u0639\u0646\u0635\u0631 \u064A\u0641\u062A\u062D \u0635\u0641\u062D\u062A\u0647 \u2014 \u0628\u062F\u0648\u0646 \u0634\u0627\u0634\u0627\u062A \u0641\u0627\u0631\u063A\u0629" },
1549
+ risk: "red"
1550
+ },
1551
+ {
1552
+ path: ANY_PAGE,
1553
+ what: { en: "Press the browser Back button after moving", ar: "\u0627\u0636\u063A\u0637 \u0632\u0631 \u0627\u0644\u0631\u062C\u0648\u0639 \u0641\u064A \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u0628\u0639\u062F \u0627\u0644\u062A\u0646\u0642\u0644" },
1554
+ expect: { en: "You land where you were, with your place kept", ar: "\u062A\u0639\u0648\u062F \u0625\u0644\u0649 \u062D\u064A\u062B \u0643\u0646\u062A \u0645\u0639 \u0628\u0642\u0627\u0621 \u0645\u0648\u0636\u0639\u0643" },
1555
+ risk: "amber"
1556
+ },
1557
+ {
1558
+ path: ANY_PAGE,
1559
+ what: { en: "Reload the page you are on", ar: "\u0623\u0639\u062F \u062A\u062D\u0645\u064A\u0644 \u0627\u0644\u0635\u0641\u062D\u0629 \u0627\u0644\u062D\u0627\u0644\u064A\u0629" },
1560
+ expect: { en: "The same page comes back, still signed in", ar: "\u062A\u0639\u0648\u062F \u0627\u0644\u0635\u0641\u062D\u0629 \u0646\u0641\u0633\u0647\u0627 \u0648\u064A\u0628\u0642\u0649 \u062A\u0633\u062C\u064A\u0644 \u0627\u0644\u062F\u062E\u0648\u0644" },
1561
+ risk: "red"
1562
+ }
1563
+ ]
1564
+ },
1565
+ {
1566
+ id: "the-main-thing",
1567
+ role: { en: "The main thing", ar: "\u0627\u0644\u0645\u0647\u0645\u0629 \u0627\u0644\u0623\u0633\u0627\u0633\u064A\u0629" },
1568
+ steps: [
1569
+ {
1570
+ path: ANY_PAGE,
1571
+ what: {
1572
+ en: "Do what this app is for, start to finish, without shortcuts",
1573
+ ar: "\u0646\u0641\u0651\u0630 \u0627\u0644\u063A\u0631\u0636 \u0627\u0644\u0623\u0633\u0627\u0633\u064A \u0645\u0646 \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0645\u0646 \u0627\u0644\u0628\u062F\u0627\u064A\u0629 \u0644\u0644\u0646\u0647\u0627\u064A\u0629 \u062F\u0648\u0646 \u0627\u062E\u062A\u0635\u0627\u0631\u0627\u062A"
1574
+ },
1575
+ expect: { en: "It completes, and you are told clearly that it did", ar: "\u062A\u0643\u062A\u0645\u0644 \u0627\u0644\u0645\u0647\u0645\u0629 \u0648\u064A\u0635\u0644\u0643 \u062A\u0623\u0643\u064A\u062F \u0648\u0627\u0636\u062D" },
1576
+ risk: "red"
1577
+ },
1578
+ {
1579
+ path: ANY_PAGE,
1580
+ what: { en: "Do it a second time with different choices", ar: "\u0643\u0631\u0631\u0647\u0627 \u0645\u0631\u0629 \u062B\u0627\u0646\u064A\u0629 \u0628\u062E\u064A\u0627\u0631\u0627\u062A \u0645\u062E\u062A\u0644\u0641\u0629" },
1581
+ expect: { en: "Same result, nothing left over from the first run", ar: "\u0627\u0644\u0646\u062A\u064A\u062C\u0629 \u0646\u0641\u0633\u0647\u0627\u060C \u062F\u0648\u0646 \u0628\u0642\u0627\u064A\u0627 \u0645\u0646 \u0627\u0644\u0645\u062D\u0627\u0648\u0644\u0629 \u0627\u0644\u0623\u0648\u0644\u0649" },
1582
+ risk: "amber"
1583
+ }
1584
+ ]
1585
+ },
1586
+ {
1587
+ id: "when-it-goes-wrong",
1588
+ role: { en: "When it goes wrong", ar: "\u0639\u0646\u062F \u0627\u0644\u062E\u0637\u0623" },
1589
+ steps: [
1590
+ {
1591
+ path: ANY_PAGE,
1592
+ what: { en: "Submit a form empty, then with obviously wrong values", ar: "\u0623\u0631\u0633\u0644 \u0627\u0644\u0646\u0645\u0648\u0630\u062C \u0641\u0627\u0631\u063A\u064B\u0627 \u062B\u0645 \u0628\u0642\u064A\u0645 \u062E\u0627\u0637\u0626\u0629 \u0628\u0648\u0636\u0648\u062D" },
1593
+ expect: {
1594
+ en: "It refuses politely and says which field and why \u2014 it does not crash or save",
1595
+ ar: "\u064A\u0631\u0641\u0636 \u0628\u0644\u0637\u0641 \u0648\u064A\u0648\u0636\u062D \u0627\u0644\u062D\u0642\u0644 \u0648\u0627\u0644\u0633\u0628\u0628 \u2014 \u062F\u0648\u0646 \u062A\u0639\u0637\u0644 \u0623\u0648 \u062D\u0641\u0638"
1596
+ },
1597
+ risk: "red"
1598
+ },
1599
+ {
1600
+ path: ANY_PAGE,
1601
+ what: { en: "Press the main button twice, quickly", ar: "\u0627\u0636\u063A\u0637 \u0627\u0644\u0632\u0631 \u0627\u0644\u0631\u0626\u064A\u0633\u064A \u0645\u0631\u062A\u064A\u0646 \u0628\u0633\u0631\u0639\u0629" },
1602
+ expect: { en: "One result, not two", ar: "\u0646\u062A\u064A\u062C\u0629 \u0648\u0627\u062D\u062F\u0629 \u0644\u0627 \u0627\u062B\u0646\u062A\u0627\u0646" },
1603
+ risk: "amber"
1604
+ },
1605
+ {
1606
+ path: ANY_PAGE,
1607
+ what: { en: "Open a page you should not be allowed to see", ar: "\u0627\u0641\u062A\u062D \u0635\u0641\u062D\u0629 \u0644\u0627 \u064A\u064F\u0641\u062A\u0631\u0636 \u0623\u0646 \u062A\u0635\u0644 \u0625\u0644\u064A\u0647\u0627" },
1608
+ expect: { en: "You are turned away, not shown someone else\u2019s data", ar: "\u064A\u062A\u0645 \u0645\u0646\u0639\u0643\u060C \u0648\u0644\u0627 \u062A\u0638\u0647\u0631 \u0628\u064A\u0627\u0646\u0627\u062A \u0634\u062E\u0635 \u0622\u062E\u0631" },
1609
+ risk: "red"
1610
+ }
1611
+ ]
1612
+ },
1613
+ {
1614
+ id: "on-a-phone",
1615
+ role: { en: "On a phone", ar: "\u0639\u0644\u0649 \u0627\u0644\u0647\u0627\u062A\u0641" },
1616
+ steps: [
1617
+ {
1618
+ path: ANY_PAGE,
1619
+ what: { en: "Make the window narrow, or open it on a phone", ar: "\u0635\u063A\u0651\u0631 \u0627\u0644\u0646\u0627\u0641\u0630\u0629 \u0623\u0648 \u0627\u0641\u062A\u062D\u0647 \u0639\u0644\u0649 \u0627\u0644\u0647\u0627\u062A\u0641" },
1620
+ expect: {
1621
+ en: "Nothing is cut off, nothing overlaps, no sideways scrolling",
1622
+ ar: "\u0644\u0627 \u0634\u064A\u0621 \u0645\u0642\u0635\u0648\u0635 \u0623\u0648 \u0645\u062A\u062F\u0627\u062E\u0644\u060C \u0648\u0628\u062F\u0648\u0646 \u062A\u0645\u0631\u064A\u0631 \u062C\u0627\u0646\u0628\u064A"
1623
+ },
1624
+ risk: "amber"
1625
+ },
1626
+ {
1627
+ path: ANY_PAGE,
1628
+ what: { en: "Tap the small controls with a thumb", ar: "\u0627\u0636\u063A\u0637 \u0627\u0644\u0639\u0646\u0627\u0635\u0631 \u0627\u0644\u0635\u063A\u064A\u0631\u0629 \u0628\u0625\u0628\u0647\u0627\u0645\u0643" },
1629
+ expect: { en: "They are big enough to hit first time", ar: "\u0643\u0628\u064A\u0631\u0629 \u0628\u0645\u0627 \u064A\u0643\u0641\u064A \u0644\u0644\u0636\u063A\u0637 \u0645\u0646 \u0623\u0648\u0644 \u0645\u0631\u0629" },
1630
+ risk: "green"
1631
+ }
1632
+ ]
1633
+ }
1634
+ ];
1635
+
1514
1636
  // src/lib/storage.ts
1515
1637
  function isStorageAvailable() {
1516
1638
  if (typeof window === "undefined") return false;
@@ -1613,6 +1735,7 @@ var STR = {
1613
1735
  loc_hide: "Hide location",
1614
1736
  loc_locate: "Locate on page",
1615
1737
  journey_title: "Testing journey",
1738
+ journey_generic: "No test plan was set up for this project, so this is a general one that fits any app. Work through it, or ask whoever set up Qapture to add the real journey to qa.config.",
1616
1739
  export_name_title: "Name your export",
1617
1740
  export_name_placeholder: "file name",
1618
1741
  tap_element: "Tap an element",
@@ -1676,6 +1799,12 @@ var STR = {
1676
1799
  sync_lost_permission: "Folder access expired \u2014 click Reconnect to keep saving",
1677
1800
  sync_write_failed: "Couldn't write to the folder \u2014 notes are still saved in this browser",
1678
1801
  sync_unsupported: "Folder saving needs Chrome, Edge or Brave on desktop. Use Export here.",
1802
+ sync_zip_mode: "This browser cannot write into a folder directly, so Qapture builds the folder for you: name the project and campaign, and you get a zip that unzips into exactly that structure. It refreshes itself every few points.",
1803
+ sync_zip_start: "Start saving",
1804
+ sync_zip_save_now: "Save folder now",
1805
+ sync_zip_saved: "Saved {name} to your downloads",
1806
+ sync_zip_failed: "Couldn't build the folder \u2014 your notes are still safe in this browser",
1807
+ sync_zip_where: "Unzip into your QA folder:",
1679
1808
  sync_writing_to: "Writing to",
1680
1809
  storage_title: "Storage",
1681
1810
  storage_used: "{used} of {quota} used",
@@ -1800,6 +1929,7 @@ var STR = {
1800
1929
  loc_hide: "\u0625\u062E\u0641\u0627\u0621 \u0627\u0644\u0645\u0648\u0642\u0639",
1801
1930
  loc_locate: "\u0625\u0638\u0647\u0627\u0631 \u0639\u0644\u0649 \u0627\u0644\u0635\u0641\u062D\u0629",
1802
1931
  journey_title: "\u0631\u062D\u0644\u0629 \u0627\u0644\u0627\u062E\u062A\u0628\u0627\u0631",
1932
+ journey_generic: "\u0644\u0645 \u064A\u062A\u0645 \u0625\u0639\u062F\u0627\u062F \u062E\u0637\u0629 \u0627\u062E\u062A\u0628\u0627\u0631 \u0644\u0647\u0630\u0627 \u0627\u0644\u0645\u0634\u0631\u0648\u0639\u060C \u0644\u0630\u0644\u0643 \u0647\u0630\u0647 \u062E\u0637\u0629 \u0639\u0627\u0645\u0629 \u062A\u0635\u0644\u062D \u0644\u0623\u064A \u062A\u0637\u0628\u064A\u0642. \u0627\u0639\u0645\u0644 \u0628\u0647\u0627\u060C \u0623\u0648 \u0627\u0637\u0644\u0628 \u0645\u0645\u0646 \u062B\u0628\u0651\u062A Qapture \u0623\u0646 \u064A\u0636\u064A\u0641 \u0631\u062D\u0644\u0629 \u0627\u0644\u0645\u0634\u0631\u0648\u0639 \u0641\u064A qa.config.",
1803
1933
  export_name_title: "\u0633\u0645\u0650\u0651 \u0645\u0644\u0641 \u0627\u0644\u062A\u0635\u062F\u064A\u0631",
1804
1934
  export_name_placeholder: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0644\u0641",
1805
1935
  tap_element: "\u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0639\u0646\u0635\u0631",
@@ -1863,6 +1993,12 @@ var STR = {
1863
1993
  sync_lost_permission: "\u0627\u0646\u062A\u0647\u062A \u0635\u0644\u0627\u062D\u064A\u0629 \u0627\u0644\u0648\u0635\u0648\u0644 \u0644\u0644\u0645\u062C\u0644\u062F \u2014 \u0627\u0636\u063A\u0637 \u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u0631\u0628\u0637 \u0644\u0644\u0645\u062A\u0627\u0628\u0639\u0629",
1864
1994
  sync_write_failed: "\u062A\u0639\u0630\u0651\u0631\u062A \u0627\u0644\u0643\u062A\u0627\u0628\u0629 \u0641\u064A \u0627\u0644\u0645\u062C\u0644\u062F \u2014 \u0627\u0644\u0645\u0644\u0627\u062D\u0638\u0627\u062A \u0645\u062D\u0641\u0648\u0638\u0629 \u0641\u064A \u0627\u0644\u0645\u062A\u0635\u0641\u062D",
1865
1995
  sync_unsupported: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F \u064A\u062A\u0637\u0644\u0628 Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0639\u0644\u0649 \u0633\u0637\u062D \u0627\u0644\u0645\u0643\u062A\u0628. \u0627\u0633\u062A\u062E\u062F\u0645 \u0627\u0644\u062A\u0635\u062F\u064A\u0631 \u0647\u0646\u0627.",
1996
+ sync_zip_mode: "\u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0627\u0644\u0643\u062A\u0627\u0628\u0629 \u062F\u0627\u062E\u0644 \u0645\u062C\u0644\u062F \u0645\u0628\u0627\u0634\u0631\u0629\u060C \u0644\u0630\u0644\u0643 \u064A\u0628\u0646\u064A Qapture \u0627\u0644\u0645\u062C\u0644\u062F \u0646\u064A\u0627\u0628\u0629\u064B \u0639\u0646\u0643: \u0627\u0643\u062A\u0628 \u0627\u0633\u0645 \u0627\u0644\u0645\u0634\u0631\u0648\u0639 \u0648\u0627\u0644\u062D\u0645\u0644\u0629\u060C \u0648\u0633\u062A\u062D\u0635\u0644 \u0639\u0644\u0649 \u0645\u0644\u0641 \u0645\u0636\u063A\u0648\u0637 \u064A\u064F\u0641\u064E\u0643 \u0625\u0644\u0649 \u0627\u0644\u0628\u0646\u064A\u0629 \u0646\u0641\u0633\u0647\u0627 \u062A\u0645\u0627\u0645\u064B\u0627. \u0648\u064A\u062A\u062D\u062F\u0651\u062B \u062A\u0644\u0642\u0627\u0626\u064A\u064B\u0627 \u0643\u0644 \u0628\u0636\u0639 \u0646\u0642\u0627\u0637.",
1997
+ sync_zip_start: "\u0627\u0628\u062F\u0623 \u0627\u0644\u062D\u0641\u0638",
1998
+ sync_zip_save_now: "\u0627\u062D\u0641\u0638 \u0627\u0644\u0645\u062C\u0644\u062F \u0627\u0644\u0622\u0646",
1999
+ sync_zip_saved: "\u062A\u0645 \u062D\u0641\u0638 {name} \u0641\u064A \u0627\u0644\u062A\u0646\u0632\u064A\u0644\u0627\u062A",
2000
+ sync_zip_failed: "\u062A\u0639\u0630\u0651\u0631 \u0628\u0646\u0627\u0621 \u0627\u0644\u0645\u062C\u0644\u062F \u2014 \u0645\u0644\u0627\u062D\u0638\u0627\u062A\u0643 \u0645\u0627 \u0632\u0627\u0644\u062A \u0645\u062D\u0641\u0648\u0638\u0629 \u0641\u064A \u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D",
2001
+ sync_zip_where: "\u0641\u064F\u0643\u0651 \u0627\u0644\u0636\u063A\u0637 \u062F\u0627\u062E\u0644 \u0645\u062C\u0644\u062F QA \u0644\u062F\u064A\u0643:",
1866
2002
  sync_writing_to: "\u0627\u0644\u0643\u062A\u0627\u0628\u0629 \u0625\u0644\u0649",
1867
2003
  storage_title: "\u0627\u0644\u062A\u062E\u0632\u064A\u0646",
1868
2004
  storage_used: "{used} \u0645\u0646 {quota} \u0645\u0633\u062A\u062E\u062F\u064E\u0645\u0629",
@@ -2249,12 +2385,153 @@ async function grabExactRegion(rect) {
2249
2385
  }
2250
2386
  }
2251
2387
 
2388
+ // src/lib/cssColors.ts
2389
+ var UNSUPPORTED_FN = /\b(?:oklch|oklab|lch|lab|color-mix|color)\(/i;
2390
+ function unsupportedFnScanner() {
2391
+ return /\b(?:oklch|oklab|lch|lab|color-mix|color)\(/gi;
2392
+ }
2393
+ var COLOR_PROPS = [
2394
+ "color",
2395
+ "backgroundColor",
2396
+ "borderTopColor",
2397
+ "borderRightColor",
2398
+ "borderBottomColor",
2399
+ "borderLeftColor",
2400
+ "outlineColor",
2401
+ "textDecorationColor",
2402
+ "columnRuleColor",
2403
+ "caretColor",
2404
+ "fill",
2405
+ "stroke"
2406
+ ];
2407
+ var COMPOUND_PROPS = ["backgroundImage", "boxShadow", "textShadow"];
2408
+ var probeCtx;
2409
+ function getProbe() {
2410
+ if (probeCtx !== void 0) return probeCtx;
2411
+ try {
2412
+ const canvas = document.createElement("canvas");
2413
+ canvas.width = canvas.height = 1;
2414
+ probeCtx = canvas.getContext("2d", { willReadFrequently: true });
2415
+ } catch {
2416
+ probeCtx = null;
2417
+ }
2418
+ return probeCtx;
2419
+ }
2420
+ var conversionCache = /* @__PURE__ */ new Map();
2421
+ function toRenderableColor(value) {
2422
+ const ctx = getProbe();
2423
+ if (!ctx || !value) return null;
2424
+ const cached = conversionCache.get(value);
2425
+ if (cached !== void 0) return cached;
2426
+ const result = convertUncached(ctx, value);
2427
+ conversionCache.set(value, result);
2428
+ return result;
2429
+ }
2430
+ function convertUncached(ctx, value) {
2431
+ try {
2432
+ ctx.fillStyle = "#000000";
2433
+ ctx.fillStyle = value;
2434
+ const fromBlack = String(ctx.fillStyle);
2435
+ ctx.fillStyle = "#ffffff";
2436
+ ctx.fillStyle = value;
2437
+ if (fromBlack !== String(ctx.fillStyle)) return null;
2438
+ if (!UNSUPPORTED_FN.test(fromBlack)) return fromBlack;
2439
+ ctx.clearRect(0, 0, 1, 1);
2440
+ ctx.fillStyle = value;
2441
+ ctx.fillRect(0, 0, 1, 1);
2442
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
2443
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
2444
+ } catch {
2445
+ return null;
2446
+ }
2447
+ }
2448
+ function neutralizeColorFunctions(value) {
2449
+ if (!value || !UNSUPPORTED_FN.test(value)) return value;
2450
+ const scanner = unsupportedFnScanner();
2451
+ let out = "";
2452
+ let last = 0;
2453
+ let match;
2454
+ while ((match = scanner.exec(value)) !== null) {
2455
+ const start = match.index;
2456
+ if (start < last) continue;
2457
+ let depth = 0;
2458
+ let i = start + match[0].length - 1;
2459
+ for (; i < value.length; i++) {
2460
+ if (value[i] === "(") depth++;
2461
+ else if (value[i] === ")") {
2462
+ depth--;
2463
+ if (depth === 0) break;
2464
+ }
2465
+ }
2466
+ if (i >= value.length) break;
2467
+ const converted = toRenderableColor(value.slice(start, i + 1));
2468
+ out += value.slice(last, start) + (converted ?? "rgba(0, 0, 0, 0)");
2469
+ last = i + 1;
2470
+ scanner.lastIndex = last;
2471
+ }
2472
+ return out + value.slice(last);
2473
+ }
2474
+ function hasUnsupportedColor(value) {
2475
+ return !!value && UNSUPPORTED_FN.test(value);
2476
+ }
2477
+ function safeBackgroundColor(value, fallback = "#ffffff") {
2478
+ if (!hasUnsupportedColor(value)) return value;
2479
+ return toRenderableColor(value) ?? fallback;
2480
+ }
2481
+ function neutralizeDocumentColors(doc, aggressive = false) {
2482
+ const view = doc.defaultView;
2483
+ if (!view || typeof view.getComputedStyle !== "function") return 0;
2484
+ let touched = 0;
2485
+ const all = doc.querySelectorAll("*");
2486
+ const elements = [doc.documentElement, ...Array.from(all)];
2487
+ for (const el of elements) {
2488
+ if (!el || !el.style) continue;
2489
+ let changed = false;
2490
+ let computed;
2491
+ try {
2492
+ computed = view.getComputedStyle(el);
2493
+ } catch {
2494
+ continue;
2495
+ }
2496
+ for (const prop of COLOR_PROPS) {
2497
+ const value = computed[prop] ?? computed[prop];
2498
+ if (!hasUnsupportedColor(value)) continue;
2499
+ const converted = toRenderableColor(value);
2500
+ if (converted) {
2501
+ el.style[prop] = converted;
2502
+ changed = true;
2503
+ }
2504
+ }
2505
+ for (const prop of COMPOUND_PROPS) {
2506
+ const value = computed[prop];
2507
+ if (!value || value === "none") continue;
2508
+ if (aggressive && prop !== "backgroundImage") {
2509
+ el.style[prop] = "none";
2510
+ changed = true;
2511
+ continue;
2512
+ }
2513
+ if (!hasUnsupportedColor(value)) continue;
2514
+ if (aggressive) {
2515
+ el.style[prop] = "none";
2516
+ changed = true;
2517
+ continue;
2518
+ }
2519
+ el.style[prop] = neutralizeColorFunctions(value);
2520
+ changed = true;
2521
+ }
2522
+ if (changed) touched++;
2523
+ }
2524
+ return touched;
2525
+ }
2526
+
2252
2527
  // src/lib/capture.ts
2253
2528
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2254
2529
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2255
2530
  var MAX_SHOT_EDGE = 1800;
2256
2531
  var WEBP_QUALITY = 0.92;
2257
2532
  var STUCK_ATTR = "data-qa-stuck";
2533
+ var MAX_CAPTURE_EDGE = 4e3;
2534
+ var MAX_RENDER_VIEWPORTS = 4;
2258
2535
  function withTimeout(promise, ms) {
2259
2536
  let timer;
2260
2537
  return Promise.race([
@@ -2420,37 +2697,86 @@ function cropCanvas(full, rect, scale) {
2420
2697
  ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
2421
2698
  return out;
2422
2699
  }
2423
- async function captureViaDom(rect, sx, sy) {
2700
+ function viewportSize() {
2701
+ return {
2702
+ vw: document.documentElement.clientWidth || window.innerWidth,
2703
+ vh: document.documentElement.clientHeight || window.innerHeight
2704
+ };
2705
+ }
2706
+ function intersectViewport(rect) {
2707
+ const { vw, vh } = viewportSize();
2708
+ const left = Math.max(0, rect.left);
2709
+ const top = Math.max(0, rect.top);
2710
+ const right = Math.min(vw, rect.left + rect.width);
2711
+ const bottom = Math.min(vh, rect.top + rect.height);
2712
+ if (right - left < 2 || bottom - top < 2) return null;
2713
+ return { left, top, width: right - left, height: bottom - top };
2714
+ }
2715
+ function planRender(rect, sx, sy, vw, vh) {
2716
+ let left = rect.left + sx;
2717
+ let top = rect.top + sy;
2718
+ let width = rect.width;
2719
+ let height = rect.height;
2720
+ if (width > MAX_CAPTURE_EDGE) {
2721
+ left = Math.max(left, Math.min(sx, left + width - MAX_CAPTURE_EDGE));
2722
+ width = MAX_CAPTURE_EDGE;
2723
+ }
2724
+ if (height > MAX_CAPTURE_EDGE) {
2725
+ top = Math.max(top, Math.min(sy, top + height - MAX_CAPTURE_EDGE));
2726
+ height = MAX_CAPTURE_EDGE;
2727
+ }
2728
+ const x = Math.floor(Math.min(sx, left));
2729
+ const y = Math.floor(Math.min(sy, top));
2730
+ const right = Math.ceil(Math.max(sx + vw, left + width));
2731
+ const bottom = Math.ceil(Math.max(sy + vh, top + height));
2732
+ return {
2733
+ render: { x, y, width: right - x, height: bottom - y },
2734
+ crop: { left: left - x, top: top - y, width, height }
2735
+ };
2736
+ }
2737
+ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2424
2738
  const { default: html2canvas } = await import('html2canvas');
2425
- const scale = Math.min(window.devicePixelRatio || 1, 2);
2426
- const vw = document.documentElement.clientWidth || window.innerWidth;
2427
- const vh = document.documentElement.clientHeight || window.innerHeight;
2739
+ const { vw, vh } = viewportSize();
2740
+ const plan = planRender(rect, sx, sy, vw, vh);
2741
+ let scale = Math.min(window.devicePixelRatio || 1, 2);
2742
+ const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2743
+ const area = plan.render.width * plan.render.height;
2744
+ if (area > budget) scale *= Math.sqrt(budget / area);
2428
2745
  const marked = markStuckElements();
2429
2746
  try {
2430
2747
  const full = await withTimeout(
2431
2748
  html2canvas(document.body, {
2432
- // Render exactly the current viewport, in document coordinates.
2433
- x: sx,
2434
- y: sy,
2435
- width: vw,
2436
- height: vh,
2749
+ // The box to render, in document coordinates.
2750
+ x: plan.render.x,
2751
+ y: plan.render.y,
2752
+ width: plan.render.width,
2753
+ height: plan.render.height,
2437
2754
  scale,
2438
2755
  useCORS: true,
2439
2756
  allowTaint: true,
2440
2757
  // The page's own background, never null — see resolvePageBackground().
2441
- backgroundColor: resolvePageBackground(),
2758
+ // Passed straight to html2canvas's parser, so it gets the same
2759
+ // CSS Color 4 treatment the document walk applies.
2760
+ backgroundColor: safeBackgroundColor(resolvePageBackground(), FALLBACK_PAGE_BACKGROUND),
2442
2761
  logging: false,
2762
+ // The clone's own scroll + viewport, which is what `position: fixed`
2763
+ // resolves against. It stays the LIVE viewport even when the render
2764
+ // box is bigger, so fixed chrome is drawn where the tester sees it
2765
+ // rather than smeared over the whole render.
2443
2766
  scrollX: sx,
2444
2767
  scrollY: sy,
2445
2768
  windowWidth: vw,
2446
2769
  windowHeight: vh,
2447
2770
  ignoreElements: (el) => el.nodeType === 1 && typeof el.hasAttribute === "function" && el.hasAttribute("data-qa-overlay"),
2448
- onclone: (doc) => pinStuckClones(doc)
2771
+ onclone: (doc) => {
2772
+ neutralizeDocumentColors(doc, aggressiveColors);
2773
+ pinStuckClones(doc);
2774
+ }
2449
2775
  }),
2450
2776
  HTML2CANVAS_TIMEOUT_MS
2451
2777
  );
2452
2778
  if (!full) return null;
2453
- return cropCanvas(full, rect, scale);
2779
+ return cropCanvas(full, plan.crop, scale);
2454
2780
  } finally {
2455
2781
  unmarkStuckElements(marked);
2456
2782
  }
@@ -2463,8 +2789,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2463
2789
  const sx = scroll?.x ?? window.scrollX;
2464
2790
  const sy = scroll?.y ?? window.scrollY;
2465
2791
  if (prefer !== "dom" && getExactCaptureStatus() === "live") {
2792
+ const visible = intersectViewport(rect);
2793
+ if (!visible) return { status: "empty" };
2466
2794
  try {
2467
- const canvas = await withOverlayHidden(() => grabExactRegion(rect));
2795
+ const canvas = await withOverlayHidden(() => grabExactRegion(visible));
2468
2796
  if (canvas) {
2469
2797
  const blob = await encodeShot(canvas);
2470
2798
  if (blob) return { status: "ok", blob, engine: "exact" };
@@ -2480,8 +2808,16 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2480
2808
  const blob = await encodeShot(canvas);
2481
2809
  return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
2482
2810
  } catch (err) {
2483
- console.warn("[QA] region capture failed:", err);
2484
- return { status: "failed" };
2811
+ console.warn("[QA] region capture failed, retrying without gradients/shadows:", err);
2812
+ try {
2813
+ const canvas = await captureViaDom(rect, sx, sy, true);
2814
+ if (!canvas) return { status: "failed" };
2815
+ const blob = await encodeShot(canvas);
2816
+ return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
2817
+ } catch (retryErr) {
2818
+ console.warn("[QA] region capture failed after retry:", retryErr);
2819
+ return { status: "failed" };
2820
+ }
2485
2821
  }
2486
2822
  }
2487
2823
 
@@ -2904,6 +3240,7 @@ async function shareZipFile(blob, filename, title) {
2904
3240
  // src/lib/fsSync.ts
2905
3241
  var MAX_SLUG_LENGTH = 48;
2906
3242
  var FS_ROOT_META_KEY = "fsSyncRoot";
3243
+ var FS_CAMPAIGN_META_KEY = "fsSyncCampaign";
2907
3244
  var root = null;
2908
3245
  var campaignDir = null;
2909
3246
  var notesDir = null;
@@ -2912,6 +3249,7 @@ var campaign = null;
2912
3249
  var noteIndex = {};
2913
3250
  var state = "unsupported";
2914
3251
  var lastError = "";
3252
+ var metaStore = null;
2915
3253
  var bulkWriting = false;
2916
3254
  var listeners = /* @__PURE__ */ new Set();
2917
3255
  function setState(next, error = "") {
@@ -2934,8 +3272,20 @@ function onFsSyncChange(fn) {
2934
3272
  function isFsSyncSupported() {
2935
3273
  return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function";
2936
3274
  }
3275
+ function isFsSyncAvailable() {
3276
+ return typeof window !== "undefined";
3277
+ }
3278
+ function getFsSyncEngine() {
3279
+ if (isFsSyncSupported() || typeof document === "undefined") return "disk";
3280
+ return "download";
3281
+ }
2937
3282
  function getFsSyncState() {
2938
- if (!isFsSyncSupported()) return "unsupported";
3283
+ if (!isFsSyncAvailable()) return "unsupported";
3284
+ if (!isFsSyncSupported()) {
3285
+ if (state === "syncing") return "syncing";
3286
+ if (state === "error") return "error";
3287
+ return "connected";
3288
+ }
2939
3289
  return state === "unsupported" ? "off" : state;
2940
3290
  }
2941
3291
  function getFsSyncError() {
@@ -2945,9 +3295,18 @@ function getFsSyncCampaign() {
2945
3295
  return campaign;
2946
3296
  }
2947
3297
  function getFsSyncPath() {
3298
+ if (getFsSyncEngine() === "download") return campaign ? campaignRelativePath() : "";
2948
3299
  if (!root) return "";
2949
3300
  if (!campaign) return root.name;
2950
- return `${root.name}/${safeSegment(campaign.project)}/${safeSegment(campaign.campaign)}`;
3301
+ return `${root.name}/${campaignRelativePath()}`;
3302
+ }
3303
+ function campaignRelativePath() {
3304
+ if (!campaign) return "";
3305
+ return `${safeSegment(campaign.project)}/${safeSegment(campaign.campaign)}`;
3306
+ }
3307
+ function campaignZipFileName() {
3308
+ if (!campaign) return "qa-campaign.zip";
3309
+ return `${safeSegment(campaign.project)} - ${safeSegment(campaign.campaign)}.zip`;
2951
3310
  }
2952
3311
  function safeSegment(input, fallback = "untitled") {
2953
3312
  let s = (input ?? "").normalize("NFC").replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-");
@@ -2989,10 +3348,25 @@ async function permissionFor(handle, request) {
2989
3348
  return "granted";
2990
3349
  }
2991
3350
  async function restoreFsSync(store) {
2992
- if (!isFsSyncSupported()) {
3351
+ metaStore = store;
3352
+ if (!isFsSyncAvailable()) {
2993
3353
  setState("unsupported");
2994
3354
  return "unsupported";
2995
3355
  }
3356
+ if (getFsSyncEngine() === "download") {
3357
+ try {
3358
+ const saved = await store.getMeta(FS_CAMPAIGN_META_KEY);
3359
+ if (saved?.campaign?.project && saved.active !== false) {
3360
+ campaign = saved.campaign;
3361
+ noteIndex = saved.notes ?? {};
3362
+ setState("syncing");
3363
+ return "syncing";
3364
+ }
3365
+ } catch {
3366
+ }
3367
+ setState("connected");
3368
+ return "connected";
3369
+ }
2996
3370
  try {
2997
3371
  const saved = await store.getMeta(FS_ROOT_META_KEY);
2998
3372
  if (!saved || typeof saved.getDirectoryHandle !== "function") {
@@ -3009,7 +3383,11 @@ async function restoreFsSync(store) {
3009
3383
  }
3010
3384
  }
3011
3385
  async function chooseFsSyncRoot(store) {
3012
- if (!isFsSyncSupported()) return false;
3386
+ metaStore = store;
3387
+ if (!isFsSyncSupported()) {
3388
+ setState("connected");
3389
+ return true;
3390
+ }
3013
3391
  const picker = window.showDirectoryPicker;
3014
3392
  try {
3015
3393
  const handle = await picker({ id: "qapture-qa", mode: "readwrite", startIn: "documents" });
@@ -3057,6 +3435,30 @@ function describeError(err) {
3057
3435
  return e?.message || "write-failed";
3058
3436
  }
3059
3437
  async function openFsCampaign(input) {
3438
+ if (getFsSyncEngine() === "download") {
3439
+ const sameAsBefore = campaign?.project === input.project && campaign?.campaign === input.campaign;
3440
+ let startedAt = sameAsBefore && campaign ? campaign.startedAt : (/* @__PURE__ */ new Date()).toISOString();
3441
+ if (!sameAsBefore) {
3442
+ noteIndex = {};
3443
+ try {
3444
+ const saved = await metaStore?.getMeta(FS_CAMPAIGN_META_KEY);
3445
+ if (saved?.campaign?.project === input.project && saved.campaign.campaign === input.campaign) {
3446
+ noteIndex = saved.notes ?? {};
3447
+ startedAt = saved.campaign.startedAt || startedAt;
3448
+ }
3449
+ } catch {
3450
+ }
3451
+ }
3452
+ campaign = {
3453
+ project: input.project,
3454
+ campaign: input.campaign,
3455
+ tester: input.tester,
3456
+ startedAt
3457
+ };
3458
+ await persistDownloadCampaign();
3459
+ setState("syncing");
3460
+ return true;
3461
+ }
3060
3462
  if (!root) return false;
3061
3463
  try {
3062
3464
  const perm = await permissionFor(root, false);
@@ -3095,11 +3497,23 @@ async function openFsCampaign(input) {
3095
3497
  }
3096
3498
  }
3097
3499
  function closeFsCampaign() {
3500
+ if (getFsSyncEngine() === "download") void persistDownloadCampaign(false);
3098
3501
  campaignDir = notesDir = shotsDir = null;
3099
3502
  campaign = null;
3100
3503
  noteIndex = {};
3504
+ if (getFsSyncEngine() === "download") {
3505
+ setState("connected");
3506
+ return;
3507
+ }
3101
3508
  if (root) setState("connected");
3102
3509
  }
3510
+ async function persistDownloadCampaign(active = true) {
3511
+ if (!metaStore || !campaign) return;
3512
+ try {
3513
+ await metaStore.setMeta(FS_CAMPAIGN_META_KEY, { campaign, notes: noteIndex, active });
3514
+ } catch {
3515
+ }
3516
+ }
3103
3517
  async function writeFile(dir, name, data) {
3104
3518
  const handle = await dir.getFileHandle(name, { create: true });
3105
3519
  const writable = await handle.createWritable();
@@ -3130,6 +3544,13 @@ function entryFor(note) {
3130
3544
  };
3131
3545
  }
3132
3546
  async function syncNoteToDisk(note, allNotes) {
3547
+ if (getFsSyncEngine() === "download") {
3548
+ if (!campaign) return false;
3549
+ noteIndex[note.id] = entryFor(note);
3550
+ await persistDownloadCampaign();
3551
+ setState("syncing");
3552
+ return true;
3553
+ }
3133
3554
  if (!campaignDir || !notesDir || !shotsDir || !campaign) return false;
3134
3555
  try {
3135
3556
  const previous = noteIndex[note.id];
@@ -3157,6 +3578,12 @@ async function syncNoteToDisk(note, allNotes) {
3157
3578
  }
3158
3579
  }
3159
3580
  async function removeNoteFromDisk(noteId, allNotes) {
3581
+ if (getFsSyncEngine() === "download") {
3582
+ if (!noteIndex[noteId]) return true;
3583
+ delete noteIndex[noteId];
3584
+ await persistDownloadCampaign();
3585
+ return true;
3586
+ }
3160
3587
  if (!campaignDir || !notesDir || !shotsDir) return false;
3161
3588
  const entry = noteIndex[noteId];
3162
3589
  if (!entry) return true;
@@ -3182,8 +3609,15 @@ function noteMarkdownForDisk(note, entry) {
3182
3609
  }
3183
3610
  async function flushCampaignFiles(allNotes) {
3184
3611
  if (!campaignDir || !campaign) return;
3185
- const known = allNotes.filter((n) => noteIndex[n.id]);
3186
- const ordered = known.slice().sort((a, b) => (noteIndex[a.id].seq ?? 0) - (noteIndex[b.id].seq ?? 0));
3612
+ await writeFile(campaignDir, "campaign.json", campaignJsonText(allNotes));
3613
+ await writeFile(campaignDir, "REPORT.md", reportMarkdownText(allNotes));
3614
+ }
3615
+ function orderedKnownNotes(allNotes) {
3616
+ return allNotes.filter((n) => noteIndex[n.id]).slice().sort((a, b) => (noteIndex[a.id].seq ?? 0) - (noteIndex[b.id].seq ?? 0));
3617
+ }
3618
+ function campaignJsonText(allNotes) {
3619
+ if (!campaign) return "{}";
3620
+ const ordered = orderedKnownNotes(allNotes);
3187
3621
  const meta = {
3188
3622
  project: campaign.project,
3189
3623
  campaign: campaign.campaign,
@@ -3193,7 +3627,11 @@ async function flushCampaignFiles(allNotes) {
3193
3627
  noteCount: ordered.length,
3194
3628
  notes: noteIndex
3195
3629
  };
3196
- await writeFile(campaignDir, "campaign.json", JSON.stringify(meta, null, 2));
3630
+ return JSON.stringify(meta, null, 2);
3631
+ }
3632
+ function reportMarkdownText(allNotes) {
3633
+ if (!campaign) return "";
3634
+ const ordered = orderedKnownNotes(allNotes);
3197
3635
  const counts = { bug: 0, question: 0, polish: 0, verified: 0 };
3198
3636
  for (const n of ordered) {
3199
3637
  const sev = n.severity ?? "bug";
@@ -3207,20 +3645,66 @@ async function flushCampaignFiles(allNotes) {
3207
3645
  "",
3208
3646
  campaign.tester ? `Tester: ${campaign.tester} ` : "",
3209
3647
  `Started: ${campaign.startedAt} `,
3210
- `Updated: ${meta.updatedAt} `,
3648
+ `Updated: ${(/* @__PURE__ */ new Date()).toISOString()} `,
3211
3649
  `Points: ${ordered.length} (${counts.bug} bug, ${counts.question} question, ${counts.polish} polish \xB7 ${counts.verified} verified)`,
3212
3650
  "",
3213
- "Written live by Qapture as each point was saved. Screenshots are in",
3651
+ getFsSyncEngine() === "download" ? "Saved by Qapture as the campaign was tested. Screenshots are in" : "Written live by Qapture as each point was saved. Screenshots are in",
3214
3652
  "`screenshots/`, one Markdown file per point in `notes/`.",
3215
3653
  "",
3216
3654
  "---",
3217
3655
  ""
3218
3656
  ].filter((line) => line !== "").join("\n");
3219
3657
  const body = ordered.map((n) => noteMarkdownForDisk(n, noteIndex[n.id])).join("\n---\n\n");
3220
- await writeFile(campaignDir, "REPORT.md", `${header}
3221
- ${body}`);
3658
+ return `${header}
3659
+ ${body}`;
3660
+ }
3661
+ async function buildCampaignZipBlob(allNotes) {
3662
+ if (!campaign || typeof document === "undefined") return null;
3663
+ try {
3664
+ const { default: JSZip } = await import('jszip');
3665
+ const zip = new JSZip();
3666
+ const base = zip.folder(safeSegment(campaign.project, "Project"))?.folder(safeSegment(campaign.campaign, defaultCampaignName()));
3667
+ if (!base) return null;
3668
+ base.file("campaign.json", campaignJsonText(allNotes));
3669
+ base.file("REPORT.md", reportMarkdownText(allNotes));
3670
+ const notesFolder = base.folder("notes");
3671
+ const shotsFolder = base.folder("screenshots");
3672
+ for (const note of orderedKnownNotes(allNotes)) {
3673
+ const entry = noteIndex[note.id];
3674
+ notesFolder?.file(entry.file, noteMarkdownForDisk(note, entry));
3675
+ if (note.screenshot && entry.shot) shotsFolder?.file(entry.shot, note.screenshot);
3676
+ if (note.afterScreenshot && entry.after) shotsFolder?.file(entry.after, note.afterScreenshot);
3677
+ }
3678
+ return await zip.generateAsync({ type: "blob" });
3679
+ } catch (err) {
3680
+ setState("error", describeError(err));
3681
+ return null;
3682
+ }
3683
+ }
3684
+ async function downloadCampaignZip(allNotes) {
3685
+ const blob = await buildCampaignZipBlob(allNotes);
3686
+ if (!blob) return false;
3687
+ const url = URL.createObjectURL(blob);
3688
+ const a = document.createElement("a");
3689
+ a.href = url;
3690
+ a.download = campaignZipFileName();
3691
+ document.body.appendChild(a);
3692
+ a.click();
3693
+ a.remove();
3694
+ setTimeout(() => URL.revokeObjectURL(url), 2e3);
3695
+ setState("syncing");
3696
+ return true;
3222
3697
  }
3223
3698
  async function syncAllToDisk(allNotes) {
3699
+ if (getFsSyncEngine() === "download") {
3700
+ if (!campaign) return false;
3701
+ for (const note of allNotes.slice().reverse()) {
3702
+ if (!noteIndex[note.id]) noteIndex[note.id] = entryFor(note);
3703
+ }
3704
+ await persistDownloadCampaign();
3705
+ setState("syncing");
3706
+ return true;
3707
+ }
3224
3708
  if (!campaignDir) return false;
3225
3709
  const ordered = allNotes.slice().reverse();
3226
3710
  bulkWriting = true;
@@ -4016,6 +4500,15 @@ function QaProvider({
4016
4500
  closeFsCampaign();
4017
4501
  notify(t("sync_paused"), { id: "sync-state" });
4018
4502
  }, [notify, t]);
4503
+ const saveSyncFolderNow = useCallback(async () => {
4504
+ const name = campaignZipFileName();
4505
+ const ok = await downloadCampaignZip(notesRef.current);
4506
+ notify(ok ? t("sync_zip_saved", { name }) : t("sync_zip_failed"), {
4507
+ tone: ok ? "success" : "error",
4508
+ id: "sync-zip"
4509
+ });
4510
+ return ok;
4511
+ }, [notify, t]);
4019
4512
  const forgetSyncFolder = useCallback(async () => {
4020
4513
  await disconnectFsSync(idb);
4021
4514
  storage.setJSON(LAST_CAMPAIGN_KEY, {});
@@ -4064,10 +4557,23 @@ function QaProvider({
4064
4557
  useEffect(() => {
4065
4558
  if (!autoBackup) return;
4066
4559
  if (notes.length === 0 || isExporting) return;
4067
- if (getFsSyncState() === "syncing") return;
4560
+ const syncing = getFsSyncState() === "syncing";
4561
+ const viaDownload = getFsSyncEngine() === "download";
4562
+ if (syncing && !viaDownload) return;
4068
4563
  const lastAt = Number(storage.getItem(AUTO_BACKUP_AT_KEY) ?? "0") || 0;
4069
4564
  if (notes.length < lastAt + AUTO_BACKUP_EVERY) return;
4070
4565
  storage.setItem(AUTO_BACKUP_AT_KEY, String(notes.length));
4566
+ if (syncing && viaDownload) {
4567
+ const name = campaignZipFileName();
4568
+ void downloadCampaignZip(notesRef.current).then((ok) => {
4569
+ notify(ok ? t("sync_zip_saved", { name }) : t("sync_zip_failed"), {
4570
+ tone: ok ? "success" : "error",
4571
+ id: "sync-zip",
4572
+ duration: 3e3
4573
+ });
4574
+ });
4575
+ return;
4576
+ }
4071
4577
  const stamp = nowIso().slice(0, 16).replace(/[:T]/g, "-");
4072
4578
  void exportZipRef.current(`qa-autosave-${stamp}-${notes.length}`).then(() => {
4073
4579
  notify(t("autosave_done", { n: notes.length }), { id: "autosave", duration: 3e3 });
@@ -4368,6 +4874,7 @@ function QaProvider({
4368
4874
  }, []);
4369
4875
  useEffect(() => {
4370
4876
  if (typeof window === "undefined") return;
4877
+ if (notesLoading) return;
4371
4878
  const params = new URLSearchParams(window.location.search);
4372
4879
  const raw = params.get("qa");
4373
4880
  if (!raw || !raw.startsWith("walk")) return;
@@ -4386,7 +4893,7 @@ function QaProvider({
4386
4893
  } else {
4387
4894
  startWalk("plan", 0);
4388
4895
  }
4389
- }, [startWalk]);
4896
+ }, [startWalk, notesLoading]);
4390
4897
  const value = {
4391
4898
  // Data
4392
4899
  notes,
@@ -4407,7 +4914,10 @@ function QaProvider({
4407
4914
  brand: config.brand,
4408
4915
  loginField: config.loginField,
4409
4916
  credentials: config.credentials,
4410
- journey: config.journey,
4917
+ // Never the raw config value: an unconfigured project falls back to the
4918
+ // generic plan so the Guide is never a blank page.
4919
+ journey: journeyOrEmpty(config.journey),
4920
+ journeyIsGeneric: !config.journey?.some((lane) => lane.steps.length > 0),
4411
4921
  preamble: config.preamble,
4412
4922
  // i18n helpers
4413
4923
  t,
@@ -4455,7 +4965,8 @@ function QaProvider({
4455
4965
  // rebuilt when fsSync's module state changes (path/campaign live outside
4456
4966
  // React).
4457
4967
  sync: {
4458
- supported: isFsSyncSupported(),
4968
+ supported: isFsSyncAvailable(),
4969
+ engine: getFsSyncEngine(),
4459
4970
  state: syncState,
4460
4971
  path: syncTick >= 0 ? getFsSyncPath() : "",
4461
4972
  campaign: getFsSyncCampaign(),
@@ -4465,6 +4976,7 @@ function QaProvider({
4465
4976
  reconnectSyncFolder,
4466
4977
  startSyncCampaign,
4467
4978
  stopSyncCampaign,
4979
+ saveSyncFolderNow,
4468
4980
  forgetSyncFolder,
4469
4981
  suggestCampaignName,
4470
4982
  lastCampaign,
@@ -4509,7 +5021,9 @@ function QaProvider({
4509
5021
  return /* @__PURE__ */ jsx(QaContext.Provider, { value, children });
4510
5022
  }
4511
5023
  function journeyOrEmpty(journey) {
4512
- return Array.isArray(journey) ? journey : [];
5024
+ const configured = Array.isArray(journey) ? journey : [];
5025
+ const hasSteps = configured.some((lane) => lane.steps.length > 0);
5026
+ return hasSteps ? configured : FALLBACK_JOURNEY;
4513
5027
  }
4514
5028
  function useQa() {
4515
5029
  const ctx = useContext(QaContext);
@@ -6490,6 +7004,7 @@ function SettingsSheet({ onClose }) {
6490
7004
  forgetSyncFolder,
6491
7005
  suggestCampaignName,
6492
7006
  lastCampaign,
7007
+ saveSyncFolderNow,
6493
7008
  storageHealth,
6494
7009
  refreshStorageHealth,
6495
7010
  requestPersistentStorage: requestPersistentStorage2,
@@ -6531,6 +7046,7 @@ function SettingsSheet({ onClose }) {
6531
7046
  }
6532
7047
  };
6533
7048
  const syncing = sync.state === "syncing";
7049
+ const viaZip = sync.engine === "download";
6534
7050
  const quotaKnown = storageHealth.supported && storageHealth.quotaBytes > 0;
6535
7051
  const usedPct = quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
6536
7052
  const meterColor = storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
@@ -6558,6 +7074,7 @@ function SettingsSheet({ onClose }) {
6558
7074
  /* @__PURE__ */ jsxs("div", { className: "qa-flex-1 qa-space-y-4 qa-overflow-y-auto qa-p-3", children: [
6559
7075
  /* @__PURE__ */ jsxs(Section, { icon: "Folder", title: t("sync_title"), children: [
6560
7076
  /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("sync_hint") }),
7077
+ viaZip ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-rounded-lg qa-border qa-border-dashed qa-border-subtle qa-p-2 qa-text-10 qa-text-mid qa-leading-relaxed", children: t("sync_zip_mode") }) : null,
6561
7078
  !sync.supported ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-rounded-lg qa-border qa-border-dashed qa-border-subtle qa-p-2 qa-text-10 qa-text-mid", children: t("sync_unsupported") }) : sync.state === "off" ? /* @__PURE__ */ jsxs(
6562
7079
  "button",
6563
7080
  {
@@ -6585,24 +7102,41 @@ function SettingsSheet({ onClose }) {
6585
7102
  ]
6586
7103
  }
6587
7104
  ) : syncing ? /* @__PURE__ */ jsxs("div", { className: "qa-space-y-2", children: [
7105
+ viaZip && /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo", children: t("sync_zip_where") }),
6588
7106
  /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-flex qa-items-center qa-gap-1.5 qa-rounded-lg qa-bg-success-tint qa-text-success qa-px-2 qa-py-1.5 qa-text-10", children: [
6589
7107
  /* @__PURE__ */ jsx(Icon, { name: "FolderCheck", size: 13, className: "qa-shrink-0" }),
6590
7108
  /* @__PURE__ */ jsx("span", { className: "qa-truncate qa-dir-ltr", dir: "ltr", title: sync.path, children: sync.path })
6591
7109
  ] }),
6592
- /* @__PURE__ */ jsx(
6593
- "button",
6594
- {
6595
- type: "button",
6596
- onClick: stopSyncCampaign,
6597
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
6598
- style: { background: "transparent", cursor: "pointer" },
6599
- children: t("sync_stop")
6600
- }
6601
- )
7110
+ /* @__PURE__ */ jsxs("div", { className: "qa-flex qa-flex-wrap qa-gap-2", children: [
7111
+ viaZip && /* @__PURE__ */ jsxs(
7112
+ "button",
7113
+ {
7114
+ type: "button",
7115
+ disabled: busy,
7116
+ onClick: () => void run2(saveSyncFolderNow),
7117
+ className: "qa-tap qa-inline-flex qa-items-center qa-gap-1.5 qa-rounded-lg qa-bg-accent qa-px-3 qa-py-1.5 qa-text-xs qa-font-semibold",
7118
+ style: { border: "none", cursor: "pointer" },
7119
+ children: [
7120
+ /* @__PURE__ */ jsx(Icon, { name: "Download", size: 13 }),
7121
+ t("sync_zip_save_now")
7122
+ ]
7123
+ }
7124
+ ),
7125
+ /* @__PURE__ */ jsx(
7126
+ "button",
7127
+ {
7128
+ type: "button",
7129
+ onClick: stopSyncCampaign,
7130
+ className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
7131
+ style: { background: "transparent", cursor: "pointer" },
7132
+ children: t("sync_stop")
7133
+ }
7134
+ )
7135
+ ] })
6602
7136
  ] }) : (
6603
7137
  /* connected: folder chosen, campaign not open yet */
6604
7138
  /* @__PURE__ */ jsxs("div", { className: "qa-space-y-2", children: [
6605
- /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-flex qa-items-center qa-gap-1.5 qa-text-10 qa-text-mid", children: [
7139
+ !viaZip && /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-flex qa-items-center qa-gap-1.5 qa-text-10 qa-text-mid", children: [
6606
7140
  /* @__PURE__ */ jsx(Icon, { name: "Folder", size: 12, className: "qa-shrink-0" }),
6607
7141
  /* @__PURE__ */ jsx("span", { className: "qa-truncate qa-dir-ltr", dir: "ltr", children: sync.path })
6608
7142
  ] }),
@@ -6624,11 +7158,11 @@ function SettingsSheet({ onClose }) {
6624
7158
  style: { border: "none", cursor: "pointer" },
6625
7159
  children: [
6626
7160
  /* @__PURE__ */ jsx(Icon, { name: "Check", size: 13 }),
6627
- t("sync_start")
7161
+ viaZip ? t("sync_zip_start") : t("sync_start")
6628
7162
  ]
6629
7163
  }
6630
7164
  ),
6631
- /* @__PURE__ */ jsx(
7165
+ !viaZip && /* @__PURE__ */ jsx(
6632
7166
  "button",
6633
7167
  {
6634
7168
  type: "button",
@@ -7138,12 +7672,31 @@ function Lane({
7138
7672
  ] });
7139
7673
  }
7140
7674
  function GuideSection() {
7141
- const { guideChecked, toggleGuide, t, journey, pick: pick2, lang, startTestAlong, startWalk, testAlongSteps } = useQa();
7675
+ const {
7676
+ guideChecked,
7677
+ toggleGuide,
7678
+ t,
7679
+ journey,
7680
+ journeyIsGeneric,
7681
+ pick: pick2,
7682
+ lang,
7683
+ startTestAlong,
7684
+ startWalk,
7685
+ testAlongSteps
7686
+ } = useQa();
7142
7687
  const all = journey.flatMap((g) => g.steps.map((s) => keyOf(g.id, s.path)));
7143
7688
  const done = all.filter((k) => guideChecked.has(k)).length;
7144
7689
  const pct = all.length > 0 ? Math.round(done / all.length * 100) : 0;
7145
7690
  const coverage = computeCoverage(journey, guideChecked);
7146
7691
  return /* @__PURE__ */ jsxs("div", { className: "qa-space-y-3", children: [
7692
+ journeyIsGeneric && /* @__PURE__ */ jsx(
7693
+ "p",
7694
+ {
7695
+ className: "qa-m-0 qa-rounded-xl qa-border qa-border-dashed qa-border-subtle qa-p-2 qa-text-10 qa-text-mid qa-leading-relaxed",
7696
+ "data-qa-generic-plan": "true",
7697
+ children: t("journey_generic")
7698
+ }
7699
+ ),
7147
7700
  /* @__PURE__ */ jsxs("div", { className: "qa-rounded-xl qa-border qa-border-accent qa-bg-accent-tint qa-p-3 qa-elev-1", children: [
7148
7701
  coverage.red.total > 0 && /* @__PURE__ */ jsxs("div", { className: "qa-mb-1 qa-flex qa-items-center qa-gap-1.5 qa-text-11", children: [
7149
7702
  /* @__PURE__ */ jsx("span", { className: "qa-bg-danger-tint qa-text-danger qa-rounded qa-px-1 qa-font-bold", children: lang === "ar" ? "\u0623\u062D\u0645\u0631" : "RED" }),
@@ -7200,8 +7753,7 @@ function GuideSection() {
7200
7753
  pick: pick2
7201
7754
  },
7202
7755
  g.id
7203
- )),
7204
- journey.length === 0 && /* @__PURE__ */ jsx("p", { className: "qa-py-8 qa-text-center qa-text-sm qa-text-slate-400", children: t("tab_guide") })
7756
+ ))
7205
7757
  ] });
7206
7758
  }
7207
7759
  var TABS = [
@@ -8046,7 +8598,7 @@ function WalkHud() {
8046
8598
  )
8047
8599
  ] });
8048
8600
  }
8049
- const onThisPage = typeof window !== "undefined" && stop.path === window.location.pathname;
8601
+ const onThisPage = stop.path === ANY_PAGE || typeof window !== "undefined" && stop.path === window.location.pathname;
8050
8602
  return /* @__PURE__ */ jsxs(
8051
8603
  "div",
8052
8604
  {
@@ -9144,6 +9696,7 @@ function CaptureMode() {
9144
9696
  type: "button",
9145
9697
  onClick: () => selection && void runCapture(selection.rect),
9146
9698
  className: "qa-tap qa-inline-flex qa-items-center qa-gap-1.5 qa-rounded-md qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-xs qa-text-mid qa-focus-ring",
9699
+ style: { background: "transparent", cursor: "pointer" },
9147
9700
  children: [
9148
9701
  /* @__PURE__ */ jsx(Icon, { name: "RotateCcw", size: 13 }),
9149
9702
  t("retry")
@@ -9443,5 +9996,5 @@ function Qapture({ config }) {
9443
9996
  }
9444
9997
 
9445
9998
  export { Qapture, deleteQaDatabase, initQaStudio };
9446
- //# sourceMappingURL=chunk-QBBWY3BF.js.map
9447
- //# sourceMappingURL=chunk-QBBWY3BF.js.map
9999
+ //# sourceMappingURL=chunk-NVFFN7SC.js.map
10000
+ //# sourceMappingURL=chunk-NVFFN7SC.js.map