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.
@@ -1518,6 +1518,128 @@ function matchRouteToSteps(journey, route) {
1518
1518
  return [...exact, ...param];
1519
1519
  }
1520
1520
 
1521
+ // src/lib/fallbackJourney.ts
1522
+ var ANY_PAGE = "*";
1523
+ var FALLBACK_JOURNEY = [
1524
+ {
1525
+ id: "first-run",
1526
+ role: { en: "First look", ar: "\u0627\u0644\u0646\u0638\u0631\u0629 \u0627\u0644\u0623\u0648\u0644\u0649" },
1527
+ steps: [
1528
+ {
1529
+ path: ANY_PAGE,
1530
+ 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" },
1531
+ expect: {
1532
+ en: "Content appears, nothing flashes or jumps, no error in the corner",
1533
+ 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"
1534
+ },
1535
+ risk: "red"
1536
+ },
1537
+ {
1538
+ path: ANY_PAGE,
1539
+ 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" },
1540
+ expect: {
1541
+ en: "It is obvious what this is and what to do next",
1542
+ 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"
1543
+ },
1544
+ risk: "amber"
1545
+ }
1546
+ ]
1547
+ },
1548
+ {
1549
+ id: "moving-around",
1550
+ role: { en: "Moving around", ar: "\u0627\u0644\u062A\u0646\u0642\u0644" },
1551
+ steps: [
1552
+ {
1553
+ path: ANY_PAGE,
1554
+ 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" },
1555
+ 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" },
1556
+ risk: "red"
1557
+ },
1558
+ {
1559
+ path: ANY_PAGE,
1560
+ 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" },
1561
+ 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" },
1562
+ risk: "amber"
1563
+ },
1564
+ {
1565
+ path: ANY_PAGE,
1566
+ 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" },
1567
+ 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" },
1568
+ risk: "red"
1569
+ }
1570
+ ]
1571
+ },
1572
+ {
1573
+ id: "the-main-thing",
1574
+ role: { en: "The main thing", ar: "\u0627\u0644\u0645\u0647\u0645\u0629 \u0627\u0644\u0623\u0633\u0627\u0633\u064A\u0629" },
1575
+ steps: [
1576
+ {
1577
+ path: ANY_PAGE,
1578
+ what: {
1579
+ en: "Do what this app is for, start to finish, without shortcuts",
1580
+ 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"
1581
+ },
1582
+ 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" },
1583
+ risk: "red"
1584
+ },
1585
+ {
1586
+ path: ANY_PAGE,
1587
+ 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" },
1588
+ 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" },
1589
+ risk: "amber"
1590
+ }
1591
+ ]
1592
+ },
1593
+ {
1594
+ id: "when-it-goes-wrong",
1595
+ role: { en: "When it goes wrong", ar: "\u0639\u0646\u062F \u0627\u0644\u062E\u0637\u0623" },
1596
+ steps: [
1597
+ {
1598
+ path: ANY_PAGE,
1599
+ 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" },
1600
+ expect: {
1601
+ en: "It refuses politely and says which field and why \u2014 it does not crash or save",
1602
+ 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"
1603
+ },
1604
+ risk: "red"
1605
+ },
1606
+ {
1607
+ path: ANY_PAGE,
1608
+ 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" },
1609
+ 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" },
1610
+ risk: "amber"
1611
+ },
1612
+ {
1613
+ path: ANY_PAGE,
1614
+ 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" },
1615
+ 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" },
1616
+ risk: "red"
1617
+ }
1618
+ ]
1619
+ },
1620
+ {
1621
+ id: "on-a-phone",
1622
+ role: { en: "On a phone", ar: "\u0639\u0644\u0649 \u0627\u0644\u0647\u0627\u062A\u0641" },
1623
+ steps: [
1624
+ {
1625
+ path: ANY_PAGE,
1626
+ 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" },
1627
+ expect: {
1628
+ en: "Nothing is cut off, nothing overlaps, no sideways scrolling",
1629
+ 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"
1630
+ },
1631
+ risk: "amber"
1632
+ },
1633
+ {
1634
+ path: ANY_PAGE,
1635
+ 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" },
1636
+ 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" },
1637
+ risk: "green"
1638
+ }
1639
+ ]
1640
+ }
1641
+ ];
1642
+
1521
1643
  // src/lib/storage.ts
1522
1644
  function isStorageAvailable() {
1523
1645
  if (typeof window === "undefined") return false;
@@ -1620,6 +1742,7 @@ var STR = {
1620
1742
  loc_hide: "Hide location",
1621
1743
  loc_locate: "Locate on page",
1622
1744
  journey_title: "Testing journey",
1745
+ 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.",
1623
1746
  export_name_title: "Name your export",
1624
1747
  export_name_placeholder: "file name",
1625
1748
  tap_element: "Tap an element",
@@ -1683,6 +1806,12 @@ var STR = {
1683
1806
  sync_lost_permission: "Folder access expired \u2014 click Reconnect to keep saving",
1684
1807
  sync_write_failed: "Couldn't write to the folder \u2014 notes are still saved in this browser",
1685
1808
  sync_unsupported: "Folder saving needs Chrome, Edge or Brave on desktop. Use Export here.",
1809
+ 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.",
1810
+ sync_zip_start: "Start saving",
1811
+ sync_zip_save_now: "Save folder now",
1812
+ sync_zip_saved: "Saved {name} to your downloads",
1813
+ sync_zip_failed: "Couldn't build the folder \u2014 your notes are still safe in this browser",
1814
+ sync_zip_where: "Unzip into your QA folder:",
1686
1815
  sync_writing_to: "Writing to",
1687
1816
  storage_title: "Storage",
1688
1817
  storage_used: "{used} of {quota} used",
@@ -1807,6 +1936,7 @@ var STR = {
1807
1936
  loc_hide: "\u0625\u062E\u0641\u0627\u0621 \u0627\u0644\u0645\u0648\u0642\u0639",
1808
1937
  loc_locate: "\u0625\u0638\u0647\u0627\u0631 \u0639\u0644\u0649 \u0627\u0644\u0635\u0641\u062D\u0629",
1809
1938
  journey_title: "\u0631\u062D\u0644\u0629 \u0627\u0644\u0627\u062E\u062A\u0628\u0627\u0631",
1939
+ 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.",
1810
1940
  export_name_title: "\u0633\u0645\u0650\u0651 \u0645\u0644\u0641 \u0627\u0644\u062A\u0635\u062F\u064A\u0631",
1811
1941
  export_name_placeholder: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0644\u0641",
1812
1942
  tap_element: "\u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0639\u0646\u0635\u0631",
@@ -1870,6 +2000,12 @@ var STR = {
1870
2000
  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",
1871
2001
  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",
1872
2002
  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.",
2003
+ 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.",
2004
+ sync_zip_start: "\u0627\u0628\u062F\u0623 \u0627\u0644\u062D\u0641\u0638",
2005
+ sync_zip_save_now: "\u0627\u062D\u0641\u0638 \u0627\u0644\u0645\u062C\u0644\u062F \u0627\u0644\u0622\u0646",
2006
+ sync_zip_saved: "\u062A\u0645 \u062D\u0641\u0638 {name} \u0641\u064A \u0627\u0644\u062A\u0646\u0632\u064A\u0644\u0627\u062A",
2007
+ 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",
2008
+ 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:",
1873
2009
  sync_writing_to: "\u0627\u0644\u0643\u062A\u0627\u0628\u0629 \u0625\u0644\u0649",
1874
2010
  storage_title: "\u0627\u0644\u062A\u062E\u0632\u064A\u0646",
1875
2011
  storage_used: "{used} \u0645\u0646 {quota} \u0645\u0633\u062A\u062E\u062F\u064E\u0645\u0629",
@@ -2256,12 +2392,153 @@ async function grabExactRegion(rect) {
2256
2392
  }
2257
2393
  }
2258
2394
 
2395
+ // src/lib/cssColors.ts
2396
+ var UNSUPPORTED_FN = /\b(?:oklch|oklab|lch|lab|color-mix|color)\(/i;
2397
+ function unsupportedFnScanner() {
2398
+ return /\b(?:oklch|oklab|lch|lab|color-mix|color)\(/gi;
2399
+ }
2400
+ var COLOR_PROPS = [
2401
+ "color",
2402
+ "backgroundColor",
2403
+ "borderTopColor",
2404
+ "borderRightColor",
2405
+ "borderBottomColor",
2406
+ "borderLeftColor",
2407
+ "outlineColor",
2408
+ "textDecorationColor",
2409
+ "columnRuleColor",
2410
+ "caretColor",
2411
+ "fill",
2412
+ "stroke"
2413
+ ];
2414
+ var COMPOUND_PROPS = ["backgroundImage", "boxShadow", "textShadow"];
2415
+ var probeCtx;
2416
+ function getProbe() {
2417
+ if (probeCtx !== void 0) return probeCtx;
2418
+ try {
2419
+ const canvas = document.createElement("canvas");
2420
+ canvas.width = canvas.height = 1;
2421
+ probeCtx = canvas.getContext("2d", { willReadFrequently: true });
2422
+ } catch {
2423
+ probeCtx = null;
2424
+ }
2425
+ return probeCtx;
2426
+ }
2427
+ var conversionCache = /* @__PURE__ */ new Map();
2428
+ function toRenderableColor(value) {
2429
+ const ctx = getProbe();
2430
+ if (!ctx || !value) return null;
2431
+ const cached = conversionCache.get(value);
2432
+ if (cached !== void 0) return cached;
2433
+ const result = convertUncached(ctx, value);
2434
+ conversionCache.set(value, result);
2435
+ return result;
2436
+ }
2437
+ function convertUncached(ctx, value) {
2438
+ try {
2439
+ ctx.fillStyle = "#000000";
2440
+ ctx.fillStyle = value;
2441
+ const fromBlack = String(ctx.fillStyle);
2442
+ ctx.fillStyle = "#ffffff";
2443
+ ctx.fillStyle = value;
2444
+ if (fromBlack !== String(ctx.fillStyle)) return null;
2445
+ if (!UNSUPPORTED_FN.test(fromBlack)) return fromBlack;
2446
+ ctx.clearRect(0, 0, 1, 1);
2447
+ ctx.fillStyle = value;
2448
+ ctx.fillRect(0, 0, 1, 1);
2449
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
2450
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
2451
+ } catch {
2452
+ return null;
2453
+ }
2454
+ }
2455
+ function neutralizeColorFunctions(value) {
2456
+ if (!value || !UNSUPPORTED_FN.test(value)) return value;
2457
+ const scanner = unsupportedFnScanner();
2458
+ let out = "";
2459
+ let last = 0;
2460
+ let match;
2461
+ while ((match = scanner.exec(value)) !== null) {
2462
+ const start = match.index;
2463
+ if (start < last) continue;
2464
+ let depth = 0;
2465
+ let i = start + match[0].length - 1;
2466
+ for (; i < value.length; i++) {
2467
+ if (value[i] === "(") depth++;
2468
+ else if (value[i] === ")") {
2469
+ depth--;
2470
+ if (depth === 0) break;
2471
+ }
2472
+ }
2473
+ if (i >= value.length) break;
2474
+ const converted = toRenderableColor(value.slice(start, i + 1));
2475
+ out += value.slice(last, start) + (converted ?? "rgba(0, 0, 0, 0)");
2476
+ last = i + 1;
2477
+ scanner.lastIndex = last;
2478
+ }
2479
+ return out + value.slice(last);
2480
+ }
2481
+ function hasUnsupportedColor(value) {
2482
+ return !!value && UNSUPPORTED_FN.test(value);
2483
+ }
2484
+ function safeBackgroundColor(value, fallback = "#ffffff") {
2485
+ if (!hasUnsupportedColor(value)) return value;
2486
+ return toRenderableColor(value) ?? fallback;
2487
+ }
2488
+ function neutralizeDocumentColors(doc, aggressive = false) {
2489
+ const view = doc.defaultView;
2490
+ if (!view || typeof view.getComputedStyle !== "function") return 0;
2491
+ let touched = 0;
2492
+ const all = doc.querySelectorAll("*");
2493
+ const elements = [doc.documentElement, ...Array.from(all)];
2494
+ for (const el of elements) {
2495
+ if (!el || !el.style) continue;
2496
+ let changed = false;
2497
+ let computed;
2498
+ try {
2499
+ computed = view.getComputedStyle(el);
2500
+ } catch {
2501
+ continue;
2502
+ }
2503
+ for (const prop of COLOR_PROPS) {
2504
+ const value = computed[prop] ?? computed[prop];
2505
+ if (!hasUnsupportedColor(value)) continue;
2506
+ const converted = toRenderableColor(value);
2507
+ if (converted) {
2508
+ el.style[prop] = converted;
2509
+ changed = true;
2510
+ }
2511
+ }
2512
+ for (const prop of COMPOUND_PROPS) {
2513
+ const value = computed[prop];
2514
+ if (!value || value === "none") continue;
2515
+ if (aggressive && prop !== "backgroundImage") {
2516
+ el.style[prop] = "none";
2517
+ changed = true;
2518
+ continue;
2519
+ }
2520
+ if (!hasUnsupportedColor(value)) continue;
2521
+ if (aggressive) {
2522
+ el.style[prop] = "none";
2523
+ changed = true;
2524
+ continue;
2525
+ }
2526
+ el.style[prop] = neutralizeColorFunctions(value);
2527
+ changed = true;
2528
+ }
2529
+ if (changed) touched++;
2530
+ }
2531
+ return touched;
2532
+ }
2533
+
2259
2534
  // src/lib/capture.ts
2260
2535
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2261
2536
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2262
2537
  var MAX_SHOT_EDGE = 1800;
2263
2538
  var WEBP_QUALITY = 0.92;
2264
2539
  var STUCK_ATTR = "data-qa-stuck";
2540
+ var MAX_CAPTURE_EDGE = 4e3;
2541
+ var MAX_RENDER_VIEWPORTS = 4;
2265
2542
  function withTimeout(promise, ms) {
2266
2543
  let timer;
2267
2544
  return Promise.race([
@@ -2427,37 +2704,86 @@ function cropCanvas(full, rect, scale) {
2427
2704
  ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
2428
2705
  return out;
2429
2706
  }
2430
- async function captureViaDom(rect, sx, sy) {
2707
+ function viewportSize() {
2708
+ return {
2709
+ vw: document.documentElement.clientWidth || window.innerWidth,
2710
+ vh: document.documentElement.clientHeight || window.innerHeight
2711
+ };
2712
+ }
2713
+ function intersectViewport(rect) {
2714
+ const { vw, vh } = viewportSize();
2715
+ const left = Math.max(0, rect.left);
2716
+ const top = Math.max(0, rect.top);
2717
+ const right = Math.min(vw, rect.left + rect.width);
2718
+ const bottom = Math.min(vh, rect.top + rect.height);
2719
+ if (right - left < 2 || bottom - top < 2) return null;
2720
+ return { left, top, width: right - left, height: bottom - top };
2721
+ }
2722
+ function planRender(rect, sx, sy, vw, vh) {
2723
+ let left = rect.left + sx;
2724
+ let top = rect.top + sy;
2725
+ let width = rect.width;
2726
+ let height = rect.height;
2727
+ if (width > MAX_CAPTURE_EDGE) {
2728
+ left = Math.max(left, Math.min(sx, left + width - MAX_CAPTURE_EDGE));
2729
+ width = MAX_CAPTURE_EDGE;
2730
+ }
2731
+ if (height > MAX_CAPTURE_EDGE) {
2732
+ top = Math.max(top, Math.min(sy, top + height - MAX_CAPTURE_EDGE));
2733
+ height = MAX_CAPTURE_EDGE;
2734
+ }
2735
+ const x = Math.floor(Math.min(sx, left));
2736
+ const y = Math.floor(Math.min(sy, top));
2737
+ const right = Math.ceil(Math.max(sx + vw, left + width));
2738
+ const bottom = Math.ceil(Math.max(sy + vh, top + height));
2739
+ return {
2740
+ render: { x, y, width: right - x, height: bottom - y },
2741
+ crop: { left: left - x, top: top - y, width, height }
2742
+ };
2743
+ }
2744
+ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2431
2745
  const { default: html2canvas } = await import('html2canvas');
2432
- const scale = Math.min(window.devicePixelRatio || 1, 2);
2433
- const vw = document.documentElement.clientWidth || window.innerWidth;
2434
- const vh = document.documentElement.clientHeight || window.innerHeight;
2746
+ const { vw, vh } = viewportSize();
2747
+ const plan = planRender(rect, sx, sy, vw, vh);
2748
+ let scale = Math.min(window.devicePixelRatio || 1, 2);
2749
+ const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2750
+ const area = plan.render.width * plan.render.height;
2751
+ if (area > budget) scale *= Math.sqrt(budget / area);
2435
2752
  const marked = markStuckElements();
2436
2753
  try {
2437
2754
  const full = await withTimeout(
2438
2755
  html2canvas(document.body, {
2439
- // Render exactly the current viewport, in document coordinates.
2440
- x: sx,
2441
- y: sy,
2442
- width: vw,
2443
- height: vh,
2756
+ // The box to render, in document coordinates.
2757
+ x: plan.render.x,
2758
+ y: plan.render.y,
2759
+ width: plan.render.width,
2760
+ height: plan.render.height,
2444
2761
  scale,
2445
2762
  useCORS: true,
2446
2763
  allowTaint: true,
2447
2764
  // The page's own background, never null — see resolvePageBackground().
2448
- backgroundColor: resolvePageBackground(),
2765
+ // Passed straight to html2canvas's parser, so it gets the same
2766
+ // CSS Color 4 treatment the document walk applies.
2767
+ backgroundColor: safeBackgroundColor(resolvePageBackground(), FALLBACK_PAGE_BACKGROUND),
2449
2768
  logging: false,
2769
+ // The clone's own scroll + viewport, which is what `position: fixed`
2770
+ // resolves against. It stays the LIVE viewport even when the render
2771
+ // box is bigger, so fixed chrome is drawn where the tester sees it
2772
+ // rather than smeared over the whole render.
2450
2773
  scrollX: sx,
2451
2774
  scrollY: sy,
2452
2775
  windowWidth: vw,
2453
2776
  windowHeight: vh,
2454
2777
  ignoreElements: (el) => el.nodeType === 1 && typeof el.hasAttribute === "function" && el.hasAttribute("data-qa-overlay"),
2455
- onclone: (doc) => pinStuckClones(doc)
2778
+ onclone: (doc) => {
2779
+ neutralizeDocumentColors(doc, aggressiveColors);
2780
+ pinStuckClones(doc);
2781
+ }
2456
2782
  }),
2457
2783
  HTML2CANVAS_TIMEOUT_MS
2458
2784
  );
2459
2785
  if (!full) return null;
2460
- return cropCanvas(full, rect, scale);
2786
+ return cropCanvas(full, plan.crop, scale);
2461
2787
  } finally {
2462
2788
  unmarkStuckElements(marked);
2463
2789
  }
@@ -2470,8 +2796,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2470
2796
  const sx = scroll?.x ?? window.scrollX;
2471
2797
  const sy = scroll?.y ?? window.scrollY;
2472
2798
  if (prefer !== "dom" && getExactCaptureStatus() === "live") {
2799
+ const visible = intersectViewport(rect);
2800
+ if (!visible) return { status: "empty" };
2473
2801
  try {
2474
- const canvas = await withOverlayHidden(() => grabExactRegion(rect));
2802
+ const canvas = await withOverlayHidden(() => grabExactRegion(visible));
2475
2803
  if (canvas) {
2476
2804
  const blob = await encodeShot(canvas);
2477
2805
  if (blob) return { status: "ok", blob, engine: "exact" };
@@ -2487,8 +2815,16 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2487
2815
  const blob = await encodeShot(canvas);
2488
2816
  return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
2489
2817
  } catch (err) {
2490
- console.warn("[QA] region capture failed:", err);
2491
- return { status: "failed" };
2818
+ console.warn("[QA] region capture failed, retrying without gradients/shadows:", err);
2819
+ try {
2820
+ const canvas = await captureViaDom(rect, sx, sy, true);
2821
+ if (!canvas) return { status: "failed" };
2822
+ const blob = await encodeShot(canvas);
2823
+ return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
2824
+ } catch (retryErr) {
2825
+ console.warn("[QA] region capture failed after retry:", retryErr);
2826
+ return { status: "failed" };
2827
+ }
2492
2828
  }
2493
2829
  }
2494
2830
 
@@ -2911,6 +3247,7 @@ async function shareZipFile(blob, filename, title) {
2911
3247
  // src/lib/fsSync.ts
2912
3248
  var MAX_SLUG_LENGTH = 48;
2913
3249
  var FS_ROOT_META_KEY = "fsSyncRoot";
3250
+ var FS_CAMPAIGN_META_KEY = "fsSyncCampaign";
2914
3251
  var root = null;
2915
3252
  var campaignDir = null;
2916
3253
  var notesDir = null;
@@ -2919,6 +3256,7 @@ var campaign = null;
2919
3256
  var noteIndex = {};
2920
3257
  var state = "unsupported";
2921
3258
  var lastError = "";
3259
+ var metaStore = null;
2922
3260
  var bulkWriting = false;
2923
3261
  var listeners = /* @__PURE__ */ new Set();
2924
3262
  function setState(next, error = "") {
@@ -2941,8 +3279,20 @@ function onFsSyncChange(fn) {
2941
3279
  function isFsSyncSupported() {
2942
3280
  return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function";
2943
3281
  }
3282
+ function isFsSyncAvailable() {
3283
+ return typeof window !== "undefined";
3284
+ }
3285
+ function getFsSyncEngine() {
3286
+ if (isFsSyncSupported() || typeof document === "undefined") return "disk";
3287
+ return "download";
3288
+ }
2944
3289
  function getFsSyncState() {
2945
- if (!isFsSyncSupported()) return "unsupported";
3290
+ if (!isFsSyncAvailable()) return "unsupported";
3291
+ if (!isFsSyncSupported()) {
3292
+ if (state === "syncing") return "syncing";
3293
+ if (state === "error") return "error";
3294
+ return "connected";
3295
+ }
2946
3296
  return state === "unsupported" ? "off" : state;
2947
3297
  }
2948
3298
  function getFsSyncError() {
@@ -2952,9 +3302,18 @@ function getFsSyncCampaign() {
2952
3302
  return campaign;
2953
3303
  }
2954
3304
  function getFsSyncPath() {
3305
+ if (getFsSyncEngine() === "download") return campaign ? campaignRelativePath() : "";
2955
3306
  if (!root) return "";
2956
3307
  if (!campaign) return root.name;
2957
- return `${root.name}/${safeSegment(campaign.project)}/${safeSegment(campaign.campaign)}`;
3308
+ return `${root.name}/${campaignRelativePath()}`;
3309
+ }
3310
+ function campaignRelativePath() {
3311
+ if (!campaign) return "";
3312
+ return `${safeSegment(campaign.project)}/${safeSegment(campaign.campaign)}`;
3313
+ }
3314
+ function campaignZipFileName() {
3315
+ if (!campaign) return "qa-campaign.zip";
3316
+ return `${safeSegment(campaign.project)} - ${safeSegment(campaign.campaign)}.zip`;
2958
3317
  }
2959
3318
  function safeSegment(input, fallback = "untitled") {
2960
3319
  let s = (input ?? "").normalize("NFC").replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-");
@@ -2996,10 +3355,25 @@ async function permissionFor(handle, request) {
2996
3355
  return "granted";
2997
3356
  }
2998
3357
  async function restoreFsSync(store) {
2999
- if (!isFsSyncSupported()) {
3358
+ metaStore = store;
3359
+ if (!isFsSyncAvailable()) {
3000
3360
  setState("unsupported");
3001
3361
  return "unsupported";
3002
3362
  }
3363
+ if (getFsSyncEngine() === "download") {
3364
+ try {
3365
+ const saved = await store.getMeta(FS_CAMPAIGN_META_KEY);
3366
+ if (saved?.campaign?.project && saved.active !== false) {
3367
+ campaign = saved.campaign;
3368
+ noteIndex = saved.notes ?? {};
3369
+ setState("syncing");
3370
+ return "syncing";
3371
+ }
3372
+ } catch {
3373
+ }
3374
+ setState("connected");
3375
+ return "connected";
3376
+ }
3003
3377
  try {
3004
3378
  const saved = await store.getMeta(FS_ROOT_META_KEY);
3005
3379
  if (!saved || typeof saved.getDirectoryHandle !== "function") {
@@ -3016,7 +3390,11 @@ async function restoreFsSync(store) {
3016
3390
  }
3017
3391
  }
3018
3392
  async function chooseFsSyncRoot(store) {
3019
- if (!isFsSyncSupported()) return false;
3393
+ metaStore = store;
3394
+ if (!isFsSyncSupported()) {
3395
+ setState("connected");
3396
+ return true;
3397
+ }
3020
3398
  const picker = window.showDirectoryPicker;
3021
3399
  try {
3022
3400
  const handle = await picker({ id: "qapture-qa", mode: "readwrite", startIn: "documents" });
@@ -3064,6 +3442,30 @@ function describeError(err) {
3064
3442
  return e?.message || "write-failed";
3065
3443
  }
3066
3444
  async function openFsCampaign(input) {
3445
+ if (getFsSyncEngine() === "download") {
3446
+ const sameAsBefore = campaign?.project === input.project && campaign?.campaign === input.campaign;
3447
+ let startedAt = sameAsBefore && campaign ? campaign.startedAt : (/* @__PURE__ */ new Date()).toISOString();
3448
+ if (!sameAsBefore) {
3449
+ noteIndex = {};
3450
+ try {
3451
+ const saved = await metaStore?.getMeta(FS_CAMPAIGN_META_KEY);
3452
+ if (saved?.campaign?.project === input.project && saved.campaign.campaign === input.campaign) {
3453
+ noteIndex = saved.notes ?? {};
3454
+ startedAt = saved.campaign.startedAt || startedAt;
3455
+ }
3456
+ } catch {
3457
+ }
3458
+ }
3459
+ campaign = {
3460
+ project: input.project,
3461
+ campaign: input.campaign,
3462
+ tester: input.tester,
3463
+ startedAt
3464
+ };
3465
+ await persistDownloadCampaign();
3466
+ setState("syncing");
3467
+ return true;
3468
+ }
3067
3469
  if (!root) return false;
3068
3470
  try {
3069
3471
  const perm = await permissionFor(root, false);
@@ -3102,11 +3504,23 @@ async function openFsCampaign(input) {
3102
3504
  }
3103
3505
  }
3104
3506
  function closeFsCampaign() {
3507
+ if (getFsSyncEngine() === "download") void persistDownloadCampaign(false);
3105
3508
  campaignDir = notesDir = shotsDir = null;
3106
3509
  campaign = null;
3107
3510
  noteIndex = {};
3511
+ if (getFsSyncEngine() === "download") {
3512
+ setState("connected");
3513
+ return;
3514
+ }
3108
3515
  if (root) setState("connected");
3109
3516
  }
3517
+ async function persistDownloadCampaign(active = true) {
3518
+ if (!metaStore || !campaign) return;
3519
+ try {
3520
+ await metaStore.setMeta(FS_CAMPAIGN_META_KEY, { campaign, notes: noteIndex, active });
3521
+ } catch {
3522
+ }
3523
+ }
3110
3524
  async function writeFile(dir, name, data) {
3111
3525
  const handle = await dir.getFileHandle(name, { create: true });
3112
3526
  const writable = await handle.createWritable();
@@ -3137,6 +3551,13 @@ function entryFor(note) {
3137
3551
  };
3138
3552
  }
3139
3553
  async function syncNoteToDisk(note, allNotes) {
3554
+ if (getFsSyncEngine() === "download") {
3555
+ if (!campaign) return false;
3556
+ noteIndex[note.id] = entryFor(note);
3557
+ await persistDownloadCampaign();
3558
+ setState("syncing");
3559
+ return true;
3560
+ }
3140
3561
  if (!campaignDir || !notesDir || !shotsDir || !campaign) return false;
3141
3562
  try {
3142
3563
  const previous = noteIndex[note.id];
@@ -3164,6 +3585,12 @@ async function syncNoteToDisk(note, allNotes) {
3164
3585
  }
3165
3586
  }
3166
3587
  async function removeNoteFromDisk(noteId, allNotes) {
3588
+ if (getFsSyncEngine() === "download") {
3589
+ if (!noteIndex[noteId]) return true;
3590
+ delete noteIndex[noteId];
3591
+ await persistDownloadCampaign();
3592
+ return true;
3593
+ }
3167
3594
  if (!campaignDir || !notesDir || !shotsDir) return false;
3168
3595
  const entry = noteIndex[noteId];
3169
3596
  if (!entry) return true;
@@ -3189,8 +3616,15 @@ function noteMarkdownForDisk(note, entry) {
3189
3616
  }
3190
3617
  async function flushCampaignFiles(allNotes) {
3191
3618
  if (!campaignDir || !campaign) return;
3192
- const known = allNotes.filter((n) => noteIndex[n.id]);
3193
- const ordered = known.slice().sort((a, b) => (noteIndex[a.id].seq ?? 0) - (noteIndex[b.id].seq ?? 0));
3619
+ await writeFile(campaignDir, "campaign.json", campaignJsonText(allNotes));
3620
+ await writeFile(campaignDir, "REPORT.md", reportMarkdownText(allNotes));
3621
+ }
3622
+ function orderedKnownNotes(allNotes) {
3623
+ return allNotes.filter((n) => noteIndex[n.id]).slice().sort((a, b) => (noteIndex[a.id].seq ?? 0) - (noteIndex[b.id].seq ?? 0));
3624
+ }
3625
+ function campaignJsonText(allNotes) {
3626
+ if (!campaign) return "{}";
3627
+ const ordered = orderedKnownNotes(allNotes);
3194
3628
  const meta = {
3195
3629
  project: campaign.project,
3196
3630
  campaign: campaign.campaign,
@@ -3200,7 +3634,11 @@ async function flushCampaignFiles(allNotes) {
3200
3634
  noteCount: ordered.length,
3201
3635
  notes: noteIndex
3202
3636
  };
3203
- await writeFile(campaignDir, "campaign.json", JSON.stringify(meta, null, 2));
3637
+ return JSON.stringify(meta, null, 2);
3638
+ }
3639
+ function reportMarkdownText(allNotes) {
3640
+ if (!campaign) return "";
3641
+ const ordered = orderedKnownNotes(allNotes);
3204
3642
  const counts = { bug: 0, question: 0, polish: 0, verified: 0 };
3205
3643
  for (const n of ordered) {
3206
3644
  const sev = n.severity ?? "bug";
@@ -3214,20 +3652,66 @@ async function flushCampaignFiles(allNotes) {
3214
3652
  "",
3215
3653
  campaign.tester ? `Tester: ${campaign.tester} ` : "",
3216
3654
  `Started: ${campaign.startedAt} `,
3217
- `Updated: ${meta.updatedAt} `,
3655
+ `Updated: ${(/* @__PURE__ */ new Date()).toISOString()} `,
3218
3656
  `Points: ${ordered.length} (${counts.bug} bug, ${counts.question} question, ${counts.polish} polish \xB7 ${counts.verified} verified)`,
3219
3657
  "",
3220
- "Written live by Qapture as each point was saved. Screenshots are in",
3658
+ 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",
3221
3659
  "`screenshots/`, one Markdown file per point in `notes/`.",
3222
3660
  "",
3223
3661
  "---",
3224
3662
  ""
3225
3663
  ].filter((line) => line !== "").join("\n");
3226
3664
  const body = ordered.map((n) => noteMarkdownForDisk(n, noteIndex[n.id])).join("\n---\n\n");
3227
- await writeFile(campaignDir, "REPORT.md", `${header}
3228
- ${body}`);
3665
+ return `${header}
3666
+ ${body}`;
3667
+ }
3668
+ async function buildCampaignZipBlob(allNotes) {
3669
+ if (!campaign || typeof document === "undefined") return null;
3670
+ try {
3671
+ const { default: JSZip } = await import('jszip');
3672
+ const zip = new JSZip();
3673
+ const base = zip.folder(safeSegment(campaign.project, "Project"))?.folder(safeSegment(campaign.campaign, defaultCampaignName()));
3674
+ if (!base) return null;
3675
+ base.file("campaign.json", campaignJsonText(allNotes));
3676
+ base.file("REPORT.md", reportMarkdownText(allNotes));
3677
+ const notesFolder = base.folder("notes");
3678
+ const shotsFolder = base.folder("screenshots");
3679
+ for (const note of orderedKnownNotes(allNotes)) {
3680
+ const entry = noteIndex[note.id];
3681
+ notesFolder?.file(entry.file, noteMarkdownForDisk(note, entry));
3682
+ if (note.screenshot && entry.shot) shotsFolder?.file(entry.shot, note.screenshot);
3683
+ if (note.afterScreenshot && entry.after) shotsFolder?.file(entry.after, note.afterScreenshot);
3684
+ }
3685
+ return await zip.generateAsync({ type: "blob" });
3686
+ } catch (err) {
3687
+ setState("error", describeError(err));
3688
+ return null;
3689
+ }
3690
+ }
3691
+ async function downloadCampaignZip(allNotes) {
3692
+ const blob = await buildCampaignZipBlob(allNotes);
3693
+ if (!blob) return false;
3694
+ const url = URL.createObjectURL(blob);
3695
+ const a = document.createElement("a");
3696
+ a.href = url;
3697
+ a.download = campaignZipFileName();
3698
+ document.body.appendChild(a);
3699
+ a.click();
3700
+ a.remove();
3701
+ setTimeout(() => URL.revokeObjectURL(url), 2e3);
3702
+ setState("syncing");
3703
+ return true;
3229
3704
  }
3230
3705
  async function syncAllToDisk(allNotes) {
3706
+ if (getFsSyncEngine() === "download") {
3707
+ if (!campaign) return false;
3708
+ for (const note of allNotes.slice().reverse()) {
3709
+ if (!noteIndex[note.id]) noteIndex[note.id] = entryFor(note);
3710
+ }
3711
+ await persistDownloadCampaign();
3712
+ setState("syncing");
3713
+ return true;
3714
+ }
3231
3715
  if (!campaignDir) return false;
3232
3716
  const ordered = allNotes.slice().reverse();
3233
3717
  bulkWriting = true;
@@ -4023,6 +4507,15 @@ function QaProvider({
4023
4507
  closeFsCampaign();
4024
4508
  notify(t("sync_paused"), { id: "sync-state" });
4025
4509
  }, [notify, t]);
4510
+ const saveSyncFolderNow = React.useCallback(async () => {
4511
+ const name = campaignZipFileName();
4512
+ const ok = await downloadCampaignZip(notesRef.current);
4513
+ notify(ok ? t("sync_zip_saved", { name }) : t("sync_zip_failed"), {
4514
+ tone: ok ? "success" : "error",
4515
+ id: "sync-zip"
4516
+ });
4517
+ return ok;
4518
+ }, [notify, t]);
4026
4519
  const forgetSyncFolder = React.useCallback(async () => {
4027
4520
  await disconnectFsSync(idb);
4028
4521
  storage.setJSON(LAST_CAMPAIGN_KEY, {});
@@ -4071,10 +4564,23 @@ function QaProvider({
4071
4564
  React.useEffect(() => {
4072
4565
  if (!autoBackup) return;
4073
4566
  if (notes.length === 0 || isExporting) return;
4074
- if (getFsSyncState() === "syncing") return;
4567
+ const syncing = getFsSyncState() === "syncing";
4568
+ const viaDownload = getFsSyncEngine() === "download";
4569
+ if (syncing && !viaDownload) return;
4075
4570
  const lastAt = Number(storage.getItem(AUTO_BACKUP_AT_KEY) ?? "0") || 0;
4076
4571
  if (notes.length < lastAt + AUTO_BACKUP_EVERY) return;
4077
4572
  storage.setItem(AUTO_BACKUP_AT_KEY, String(notes.length));
4573
+ if (syncing && viaDownload) {
4574
+ const name = campaignZipFileName();
4575
+ void downloadCampaignZip(notesRef.current).then((ok) => {
4576
+ notify(ok ? t("sync_zip_saved", { name }) : t("sync_zip_failed"), {
4577
+ tone: ok ? "success" : "error",
4578
+ id: "sync-zip",
4579
+ duration: 3e3
4580
+ });
4581
+ });
4582
+ return;
4583
+ }
4078
4584
  const stamp = nowIso().slice(0, 16).replace(/[:T]/g, "-");
4079
4585
  void exportZipRef.current(`qa-autosave-${stamp}-${notes.length}`).then(() => {
4080
4586
  notify(t("autosave_done", { n: notes.length }), { id: "autosave", duration: 3e3 });
@@ -4375,6 +4881,7 @@ function QaProvider({
4375
4881
  }, []);
4376
4882
  React.useEffect(() => {
4377
4883
  if (typeof window === "undefined") return;
4884
+ if (notesLoading) return;
4378
4885
  const params = new URLSearchParams(window.location.search);
4379
4886
  const raw = params.get("qa");
4380
4887
  if (!raw || !raw.startsWith("walk")) return;
@@ -4393,7 +4900,7 @@ function QaProvider({
4393
4900
  } else {
4394
4901
  startWalk("plan", 0);
4395
4902
  }
4396
- }, [startWalk]);
4903
+ }, [startWalk, notesLoading]);
4397
4904
  const value = {
4398
4905
  // Data
4399
4906
  notes,
@@ -4414,7 +4921,10 @@ function QaProvider({
4414
4921
  brand: config.brand,
4415
4922
  loginField: config.loginField,
4416
4923
  credentials: config.credentials,
4417
- journey: config.journey,
4924
+ // Never the raw config value: an unconfigured project falls back to the
4925
+ // generic plan so the Guide is never a blank page.
4926
+ journey: journeyOrEmpty(config.journey),
4927
+ journeyIsGeneric: !config.journey?.some((lane) => lane.steps.length > 0),
4418
4928
  preamble: config.preamble,
4419
4929
  // i18n helpers
4420
4930
  t,
@@ -4462,7 +4972,8 @@ function QaProvider({
4462
4972
  // rebuilt when fsSync's module state changes (path/campaign live outside
4463
4973
  // React).
4464
4974
  sync: {
4465
- supported: isFsSyncSupported(),
4975
+ supported: isFsSyncAvailable(),
4976
+ engine: getFsSyncEngine(),
4466
4977
  state: syncState,
4467
4978
  path: syncTick >= 0 ? getFsSyncPath() : "",
4468
4979
  campaign: getFsSyncCampaign(),
@@ -4472,6 +4983,7 @@ function QaProvider({
4472
4983
  reconnectSyncFolder,
4473
4984
  startSyncCampaign,
4474
4985
  stopSyncCampaign,
4986
+ saveSyncFolderNow,
4475
4987
  forgetSyncFolder,
4476
4988
  suggestCampaignName,
4477
4989
  lastCampaign,
@@ -4516,7 +5028,9 @@ function QaProvider({
4516
5028
  return /* @__PURE__ */ jsxRuntime.jsx(QaContext.Provider, { value, children });
4517
5029
  }
4518
5030
  function journeyOrEmpty(journey) {
4519
- return Array.isArray(journey) ? journey : [];
5031
+ const configured = Array.isArray(journey) ? journey : [];
5032
+ const hasSteps = configured.some((lane) => lane.steps.length > 0);
5033
+ return hasSteps ? configured : FALLBACK_JOURNEY;
4520
5034
  }
4521
5035
  function useQa() {
4522
5036
  const ctx = React.useContext(QaContext);
@@ -6497,6 +7011,7 @@ function SettingsSheet({ onClose }) {
6497
7011
  forgetSyncFolder,
6498
7012
  suggestCampaignName,
6499
7013
  lastCampaign,
7014
+ saveSyncFolderNow,
6500
7015
  storageHealth,
6501
7016
  refreshStorageHealth,
6502
7017
  requestPersistentStorage: requestPersistentStorage2,
@@ -6538,6 +7053,7 @@ function SettingsSheet({ onClose }) {
6538
7053
  }
6539
7054
  };
6540
7055
  const syncing = sync.state === "syncing";
7056
+ const viaZip = sync.engine === "download";
6541
7057
  const quotaKnown = storageHealth.supported && storageHealth.quotaBytes > 0;
6542
7058
  const usedPct = quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
6543
7059
  const meterColor = storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
@@ -6565,6 +7081,7 @@ function SettingsSheet({ onClose }) {
6565
7081
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex-1 qa-space-y-4 qa-overflow-y-auto qa-p-3", children: [
6566
7082
  /* @__PURE__ */ jsxRuntime.jsxs(Section, { icon: "Folder", title: t("sync_title"), children: [
6567
7083
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("sync_hint") }),
7084
+ viaZip ? /* @__PURE__ */ jsxRuntime.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,
6568
7085
  !sync.supported ? /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsxs(
6569
7086
  "button",
6570
7087
  {
@@ -6592,24 +7109,41 @@ function SettingsSheet({ onClose }) {
6592
7109
  ]
6593
7110
  }
6594
7111
  ) : syncing ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-space-y-2", children: [
7112
+ viaZip && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo", children: t("sync_zip_where") }),
6595
7113
  /* @__PURE__ */ jsxRuntime.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: [
6596
7114
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "FolderCheck", size: 13, className: "qa-shrink-0" }),
6597
7115
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-truncate qa-dir-ltr", dir: "ltr", title: sync.path, children: sync.path })
6598
7116
  ] }),
6599
- /* @__PURE__ */ jsxRuntime.jsx(
6600
- "button",
6601
- {
6602
- type: "button",
6603
- onClick: stopSyncCampaign,
6604
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
6605
- style: { background: "transparent", cursor: "pointer" },
6606
- children: t("sync_stop")
6607
- }
6608
- )
7117
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex qa-flex-wrap qa-gap-2", children: [
7118
+ viaZip && /* @__PURE__ */ jsxRuntime.jsxs(
7119
+ "button",
7120
+ {
7121
+ type: "button",
7122
+ disabled: busy,
7123
+ onClick: () => void run2(saveSyncFolderNow),
7124
+ 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",
7125
+ style: { border: "none", cursor: "pointer" },
7126
+ children: [
7127
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Download", size: 13 }),
7128
+ t("sync_zip_save_now")
7129
+ ]
7130
+ }
7131
+ ),
7132
+ /* @__PURE__ */ jsxRuntime.jsx(
7133
+ "button",
7134
+ {
7135
+ type: "button",
7136
+ onClick: stopSyncCampaign,
7137
+ className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
7138
+ style: { background: "transparent", cursor: "pointer" },
7139
+ children: t("sync_stop")
7140
+ }
7141
+ )
7142
+ ] })
6609
7143
  ] }) : (
6610
7144
  /* connected: folder chosen, campaign not open yet */
6611
7145
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-space-y-2", children: [
6612
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-m-0 qa-flex qa-items-center qa-gap-1.5 qa-text-10 qa-text-mid", children: [
7146
+ !viaZip && /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-m-0 qa-flex qa-items-center qa-gap-1.5 qa-text-10 qa-text-mid", children: [
6613
7147
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Folder", size: 12, className: "qa-shrink-0" }),
6614
7148
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-truncate qa-dir-ltr", dir: "ltr", children: sync.path })
6615
7149
  ] }),
@@ -6631,11 +7165,11 @@ function SettingsSheet({ onClose }) {
6631
7165
  style: { border: "none", cursor: "pointer" },
6632
7166
  children: [
6633
7167
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Check", size: 13 }),
6634
- t("sync_start")
7168
+ viaZip ? t("sync_zip_start") : t("sync_start")
6635
7169
  ]
6636
7170
  }
6637
7171
  ),
6638
- /* @__PURE__ */ jsxRuntime.jsx(
7172
+ !viaZip && /* @__PURE__ */ jsxRuntime.jsx(
6639
7173
  "button",
6640
7174
  {
6641
7175
  type: "button",
@@ -7145,12 +7679,31 @@ function Lane({
7145
7679
  ] });
7146
7680
  }
7147
7681
  function GuideSection() {
7148
- const { guideChecked, toggleGuide, t, journey, pick: pick2, lang, startTestAlong, startWalk, testAlongSteps } = useQa();
7682
+ const {
7683
+ guideChecked,
7684
+ toggleGuide,
7685
+ t,
7686
+ journey,
7687
+ journeyIsGeneric,
7688
+ pick: pick2,
7689
+ lang,
7690
+ startTestAlong,
7691
+ startWalk,
7692
+ testAlongSteps
7693
+ } = useQa();
7149
7694
  const all = journey.flatMap((g) => g.steps.map((s) => keyOf(g.id, s.path)));
7150
7695
  const done = all.filter((k) => guideChecked.has(k)).length;
7151
7696
  const pct = all.length > 0 ? Math.round(done / all.length * 100) : 0;
7152
7697
  const coverage = computeCoverage(journey, guideChecked);
7153
7698
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-space-y-3", children: [
7699
+ journeyIsGeneric && /* @__PURE__ */ jsxRuntime.jsx(
7700
+ "p",
7701
+ {
7702
+ 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",
7703
+ "data-qa-generic-plan": "true",
7704
+ children: t("journey_generic")
7705
+ }
7706
+ ),
7154
7707
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-rounded-xl qa-border qa-border-accent qa-bg-accent-tint qa-p-3 qa-elev-1", children: [
7155
7708
  coverage.red.total > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-mb-1 qa-flex qa-items-center qa-gap-1.5 qa-text-11", children: [
7156
7709
  /* @__PURE__ */ jsxRuntime.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" }),
@@ -7207,8 +7760,7 @@ function GuideSection() {
7207
7760
  pick: pick2
7208
7761
  },
7209
7762
  g.id
7210
- )),
7211
- journey.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-py-8 qa-text-center qa-text-sm qa-text-slate-400", children: t("tab_guide") })
7763
+ ))
7212
7764
  ] });
7213
7765
  }
7214
7766
  var TABS = [
@@ -8053,7 +8605,7 @@ function WalkHud() {
8053
8605
  )
8054
8606
  ] });
8055
8607
  }
8056
- const onThisPage = typeof window !== "undefined" && stop.path === window.location.pathname;
8608
+ const onThisPage = stop.path === ANY_PAGE || typeof window !== "undefined" && stop.path === window.location.pathname;
8057
8609
  return /* @__PURE__ */ jsxRuntime.jsxs(
8058
8610
  "div",
8059
8611
  {
@@ -9151,6 +9703,7 @@ function CaptureMode() {
9151
9703
  type: "button",
9152
9704
  onClick: () => selection && void runCapture(selection.rect),
9153
9705
  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",
9706
+ style: { background: "transparent", cursor: "pointer" },
9154
9707
  children: [
9155
9708
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "RotateCcw", size: 13 }),
9156
9709
  t("retry")
@@ -9452,5 +10005,5 @@ function Qapture({ config }) {
9452
10005
  exports.Qapture = Qapture;
9453
10006
  exports.deleteQaDatabase = deleteQaDatabase;
9454
10007
  exports.initQaStudio = initQaStudio;
9455
- //# sourceMappingURL=chunk-UOUFJCPF.cjs.map
9456
- //# sourceMappingURL=chunk-UOUFJCPF.cjs.map
10008
+ //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10009
+ //# sourceMappingURL=chunk-CIFJMD2B.cjs.map