qapture2 0.7.9 → 0.8.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.
@@ -51,10 +51,10 @@ function openDB(dbName) {
51
51
  dbCache.set(dbName, promise);
52
52
  return promise;
53
53
  }
54
- function run(dbName, store, mode2, fn) {
54
+ function run(dbName, store, mode, fn) {
55
55
  return openDB(dbName).then(
56
56
  (db) => new Promise((resolve, reject) => {
57
- const tx = db.transaction(store, mode2);
57
+ const tx = db.transaction(store, mode);
58
58
  const s = tx.objectStore(store);
59
59
  let result;
60
60
  const req = fn(s);
@@ -509,6 +509,7 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
509
509
  /* Z-index scale */
510
510
  --qa-z-fab: 9990;
511
511
  --qa-z-panel: 9995;
512
+ --qa-z-capture-still: 10089;
512
513
  --qa-z-capture-dim: 10090;
513
514
  --qa-z-capture-highlight: 10092;
514
515
  --qa-z-capture-region-move: 10093;
@@ -558,6 +559,8 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
558
559
  .qa-z-1 { z-index: 1; }
559
560
  .qa-z-50 { z-index: 50; }
560
561
  .qa-z-100 { z-index: 100; }
562
+ /* the frozen still, directly beneath the dim so the scrim tints it too */
563
+ .qa-z-10089 { z-index: var(--qa-z-capture-still); }
561
564
  .qa-z-10090 { z-index: var(--qa-z-capture-dim); }
562
565
  .qa-z-10092 { z-index: var(--qa-z-capture-highlight); }
563
566
  /* region-handle layering */
@@ -1722,11 +1725,15 @@ var STR = {
1722
1725
  sel_region: "Region selected",
1723
1726
  sel_element: "Element selected",
1724
1727
  capturing: "capturing screenshot\u2026",
1728
+ too_heavy: "This page is too big to redraw \u2014 the tab would freeze. Photograph it instead: one frame, and maps and charts come out right.",
1725
1729
  no_shot: "no screenshot (location saved)",
1726
1730
  annotate_placeholder: "What do you want to do here? (add / remove / change\u2026)",
1727
1731
  save_point: "Save point",
1732
+ save_next: "Save + next",
1733
+ save_next_hint: "Save this one and mark up another part of the same screenshot \u2014 no second permission prompt.",
1734
+ shot_reused: "{n} saved from this screenshot",
1728
1735
  reselect: "Reselect",
1729
- save_hint: "\u2318/Ctrl + Enter to save",
1736
+ save_hint: "\u2318/Ctrl + Enter to save \xB7 add Shift to keep the same screenshot",
1730
1737
  login_with: "Log in with {field} + password.",
1731
1738
  used_count: "{n}/{m} used",
1732
1739
  used: "used",
@@ -1762,6 +1769,9 @@ var STR = {
1762
1769
  sev_design: "Design",
1763
1770
  sev_enhance: "Enhance",
1764
1771
  status_open: "Open",
1772
+ status_sent: "Sent",
1773
+ check_fixes: "Check the {n} fixes",
1774
+ check_fixes_hint: "Walk back through everything the last export sent, one spot at a time, and say whether each is now what you asked for.",
1765
1775
  status_verified: "Verified",
1766
1776
  context_attached: "{n} runtime events attached",
1767
1777
  start_walkthrough: "Start walkthrough",
@@ -1776,7 +1786,7 @@ var STR = {
1776
1786
  no_evidence: "ticked, no capture",
1777
1787
  expected_label: "Expected",
1778
1788
  exact_label: "Pixel-exact shots",
1779
- exact_hint: "Screenshots become a real photo instead of a redraw. Asks once to share this tab (or, in Safari and Firefox, this window); nothing leaves your device.",
1789
+ exact_hint: "Screenshots become a real photo instead of a redraw. Each capture asks to share this tab (or, in Safari and Firefox, this window), takes one frame, and hands the screen straight back \u2014 nothing keeps recording, and nothing leaves your device.",
1780
1790
  redraw_notice: "This is a redraw of the page, not a real photo.",
1781
1791
  redraw_action: "Use real screenshots",
1782
1792
  redraw_no_engine: "This browser cannot photograph the screen at all, so charts and canvases may come out wrong. Open the app on a desktop browser for true screenshots.",
@@ -1785,7 +1795,7 @@ var STR = {
1785
1795
  exact_off: "Back to redrawn screenshots",
1786
1796
  exact_declined: "Staying on redrawn screenshots",
1787
1797
  exact_turn_on: "Turn on",
1788
- exact_unsupported: "Needs Chrome, Edge or Brave on desktop",
1798
+ exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1789
1799
  sync_title: "Save to a folder",
1790
1800
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1791
1801
  sync_choose: "Choose folder",
@@ -1930,11 +1940,15 @@ var STR = {
1930
1940
  sel_region: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0645\u0646\u0637\u0642\u0629",
1931
1941
  sel_element: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0639\u0646\u0635\u0631",
1932
1942
  capturing: "\u064A\u062A\u0645 \u0627\u0644\u062A\u0642\u0627\u0637 \u0627\u0644\u0635\u0648\u0631\u0629\u2026",
1943
+ too_heavy: "\u0647\u0630\u0647 \u0627\u0644\u0635\u0641\u062D\u0629 \u0623\u0643\u0628\u0631 \u0645\u0646 \u0623\u0646 \u064A\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627 \u2014 \u0633\u064A\u062A\u062C\u0645\u0651\u062F \u0627\u0644\u062A\u0628\u0648\u064A\u0628. \u0635\u0648\u0651\u0631\u0647\u0627 \u0628\u062F\u0644 \u0630\u0644\u0643: \u0625\u0637\u0627\u0631 \u0648\u0627\u062D\u062F\u060C \u0648\u062A\u0638\u0647\u0631 \u0627\u0644\u062E\u0631\u0627\u0626\u0637 \u0648\u0627\u0644\u0631\u0633\u0648\u0645 \u0643\u0645\u0627 \u0647\u064A.",
1933
1944
  no_shot: "\u0628\u062F\u0648\u0646 \u0635\u0648\u0631\u0629 (\u062A\u0645 \u062D\u0641\u0638 \u0627\u0644\u0645\u0648\u0642\u0639)",
1934
1945
  annotate_placeholder: "\u0645\u0627\u0630\u0627 \u062A\u0631\u064A\u062F \u0623\u0646 \u062A\u0641\u0639\u0644 \u0647\u0646\u0627\u061F (\u0625\u0636\u0627\u0641\u0629 / \u062D\u0630\u0641 / \u062A\u063A\u064A\u064A\u0631\u2026)",
1935
1946
  save_point: "\u062D\u0641\u0638 \u0627\u0644\u0646\u0642\u0637\u0629",
1947
+ save_next: "\u062D\u0641\u0638 + \u0627\u0644\u062A\u0627\u0644\u064A",
1948
+ save_next_hint: "\u0627\u062D\u0641\u0638 \u0647\u0630\u0647 \u0648\u062D\u062F\u0651\u062F \u062C\u0632\u0621\u064B\u0627 \u0622\u062E\u0631 \u0645\u0646 \u0646\u0641\u0633 \u0627\u0644\u0644\u0642\u0637\u0629 \u2014 \u0628\u062F\u0648\u0646 \u0637\u0644\u0628 \u0625\u0630\u0646 \u062C\u062F\u064A\u062F.",
1949
+ shot_reused: "\u062D\u064F\u0641\u0638\u062A {n} \u0645\u0646 \u0647\u0630\u0647 \u0627\u0644\u0644\u0642\u0637\u0629",
1936
1950
  reselect: "\u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u062A\u062D\u062F\u064A\u062F",
1937
- save_hint: "\u2318/Ctrl + Enter \u0644\u0644\u062D\u0641\u0638",
1951
+ save_hint: "\u2318/Ctrl + Enter \u0644\u0644\u062D\u0641\u0638 \xB7 \u0623\u0636\u0641 Shift \u0644\u0644\u0628\u0642\u0627\u0621 \u0639\u0644\u0649 \u0646\u0641\u0633 \u0627\u0644\u0644\u0642\u0637\u0629",
1938
1952
  login_with: "\u0633\u062C\u0651\u0644 \u0627\u0644\u062F\u062E\u0648\u0644 \u0628\u0640 {field} + \u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631.",
1939
1953
  used_count: "{n}/{m} \u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
1940
1954
  used: "\u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
@@ -1970,6 +1984,9 @@ var STR = {
1970
1984
  sev_design: "Design",
1971
1985
  sev_enhance: "Enhance",
1972
1986
  status_open: "\u0645\u0641\u062A\u0648\u062D",
1987
+ status_sent: "\u0645\u064F\u0631\u0633\u064E\u0644",
1988
+ check_fixes: "\u062A\u062D\u0642\u0651\u0642 \u0645\u0646 {n} \u062A\u0639\u062F\u064A\u0644\u0627\u062A",
1989
+ check_fixes_hint: "\u0645\u0631\u0651 \u0639\u0644\u0649 \u0643\u0644 \u0645\u0627 \u0623\u0631\u0633\u0644\u0647 \u0627\u0644\u062A\u0635\u062F\u064A\u0631 \u0627\u0644\u0623\u062E\u064A\u0631\u060C \u0646\u0642\u0637\u0629 \u0646\u0642\u0637\u0629\u060C \u0648\u0642\u0644 \u0647\u0644 \u0635\u0627\u0631 \u0643\u0645\u0627 \u0637\u0644\u0628\u062A \u0623\u0645 \u0644\u0627.",
1973
1990
  status_verified: "\u062A\u0645 \u0627\u0644\u062A\u062D\u0642\u0642",
1974
1991
  context_attached: "{n} \u0645\u0646 \u0623\u062D\u062F\u0627\u062B \u0627\u0644\u062A\u0634\u063A\u064A\u0644 \u0645\u0631\u0641\u0642\u0629",
1975
1992
  start_walkthrough: "\u0627\u0628\u062F\u0623 \u0627\u0644\u062C\u0648\u0644\u0629",
@@ -1984,7 +2001,7 @@ var STR = {
1984
2001
  no_evidence: "\u0645\u064F\u0639\u0644\u0651\u0645 \u0628\u062F\u0648\u0646 \u0627\u0644\u062A\u0642\u0627\u0637",
1985
2002
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1986
2003
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1987
- exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u0631\u0633\u0645. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629 \u0644\u0645\u0634\u0627\u0631\u0643\u0629 \u0627\u0644\u062A\u0628\u0648\u064A\u0628 (\u0623\u0648 \u0627\u0644\u0646\u0627\u0641\u0630\u0629 \u0641\u064A Safari \u0648Firefox)\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
2004
+ exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u0631\u0633\u0645. \u062A\u0637\u0644\u0628 \u0643\u0644 \u0644\u0642\u0637\u0629 \u0625\u0630\u0646 \u0645\u0634\u0627\u0631\u0643\u0629 \u0627\u0644\u062A\u0628\u0648\u064A\u0628 (\u0623\u0648 \u0627\u0644\u0646\u0627\u0641\u0630\u0629 \u0641\u064A Safari \u0648Firefox)\u060C \u062A\u0623\u062E\u0630 \u0625\u0637\u0627\u0631\u064B\u0627 \u0648\u0627\u062D\u062F\u064B\u0627\u060C \u062B\u0645 \u062A\u0639\u064A\u062F \u0627\u0644\u0634\u0627\u0634\u0629 \u0641\u0648\u0631\u064B\u0627 \u2014 \u0644\u0627 \u062A\u0633\u062C\u064A\u0644 \u0645\u0633\u062A\u0645\u0631\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1988
2005
  redraw_notice: "\u0647\u0630\u0647 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645 \u0644\u0644\u0635\u0641\u062D\u0629\u060C \u0648\u0644\u064A\u0633\u062A \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629.",
1989
2006
  redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1990
2007
  redraw_no_engine: "\u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629 \u0625\u0637\u0644\u0627\u0642\u064B\u0627\u060C \u0644\u0630\u0627 \u0642\u062F \u062A\u0638\u0647\u0631 \u0627\u0644\u0645\u062E\u0637\u0637\u0627\u062A \u0648\u0627\u0644\u0644\u0648\u062D\u0627\u062A \u0628\u0634\u0643\u0644 \u062E\u0627\u0637\u0626. \u0627\u0641\u062A\u062D \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0639\u0644\u0649 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
@@ -1993,7 +2010,7 @@ var STR = {
1993
2010
  exact_off: "\u0627\u0644\u0639\u0648\u062F\u0629 \u0625\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
1994
2011
  exact_declined: "\u0633\u0646\u0628\u0642\u0649 \u0639\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
1995
2012
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
1996
- exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0639\u0644\u0649 \u0633\u0637\u062D \u0627\u0644\u0645\u0643\u062A\u0628",
2013
+ exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u2014 \u0627\u0644\u0647\u0648\u0627\u062A\u0641 \u0644\u0627 \u062A\u0633\u062A\u0637\u064A\u0639 \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629",
1997
2014
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
1998
2015
  sync_hint: "\u062A\u064F\u0643\u062A\u0628 \u0643\u0644 \u0645\u0644\u0627\u062D\u0638\u0629 \u0639\u0644\u0649 \u0627\u0644\u0642\u0631\u0635 \u0644\u062D\u0638\u0629 \u062D\u0641\u0638\u0647\u0627 \u2014 \u0644\u0627 \u0634\u064A\u0621 \u064A\u0636\u064A\u0639 \u0625\u0630\u0627 \u062A\u0639\u0637\u0651\u0644 \u0627\u0644\u0645\u062A\u0635\u0641\u062D.",
1999
2016
  sync_choose: "\u0627\u062E\u062A\u0631 \u0627\u0644\u0645\u062C\u0644\u062F",
@@ -2298,7 +2315,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2298
2315
  if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > SQUARENESS_TOLERANCE) return null;
2299
2316
  const originX = tl.x - centres.tl.x * scaleX;
2300
2317
  const originY = tl.y - centres.tl.y * scaleY;
2301
- const mapping2 = { scaleX, scaleY, originX, originY };
2318
+ const mapping = { scaleX, scaleY, originX, originY };
2302
2319
  const farX = originX + vw * scaleX;
2303
2320
  const farY = originY + vh * scaleY;
2304
2321
  const slack = Math.max(4, frameW * 0.01);
@@ -2317,7 +2334,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2317
2334
  verified++;
2318
2335
  }
2319
2336
  if (verified === 0) return null;
2320
- return mapping2;
2337
+ return mapping;
2321
2338
  }
2322
2339
  function mapRectToFrame(rect, m, frameW, frameH) {
2323
2340
  const x0 = m.originX + rect.left * m.scaleX;
@@ -2329,73 +2346,49 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2329
2346
  if (sw < 1 || sh < 1) return null;
2330
2347
  return { sx, sy, sw, sh };
2331
2348
  }
2332
- function environmentSignature() {
2333
- if (typeof window === "undefined") return "";
2334
- return [
2335
- window.innerWidth,
2336
- window.innerHeight,
2337
- window.outerWidth,
2338
- window.outerHeight,
2339
- window.screenX,
2340
- window.screenY,
2341
- Math.round((window.devicePixelRatio || 1) * 100)
2342
- ].join("x");
2343
- }
2344
2349
 
2345
2350
  // src/lib/screenCapture.ts
2346
2351
  var ASPECT_TOLERANCE = 0.08;
2347
2352
  var CALIBRATION_SETTLE_MS = 220;
2348
2353
  var FRESH_FRAME_TIMEOUT_MS = 500;
2349
2354
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2350
- var stream = null;
2351
- var track = null;
2352
- var video = null;
2353
- var grabber = null;
2354
- var declined = false;
2355
2355
  function isExactCaptureSupported() {
2356
2356
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2357
2357
  const md = navigator.mediaDevices;
2358
2358
  return !!md && typeof md.getDisplayMedia === "function";
2359
2359
  }
2360
- var mode = "tab";
2361
- var mapping = null;
2362
- var mappingSignature = "";
2360
+ var armed = false;
2361
+ var declined = false;
2362
+ var frozen = null;
2363
+ var lastMode = null;
2364
+ function armExactCapture() {
2365
+ if (!isExactCaptureSupported()) return false;
2366
+ armed = true;
2367
+ declined = false;
2368
+ return true;
2369
+ }
2370
+ function disarmExactCapture() {
2371
+ armed = false;
2372
+ releaseFrozenFrame();
2373
+ }
2374
+ function resetExactCaptureDecline() {
2375
+ declined = false;
2376
+ }
2363
2377
  function getExactCaptureStatus() {
2364
2378
  if (!isExactCaptureSupported()) return "unsupported";
2365
- if (track && track.readyState === "live") return "live";
2379
+ if (armed) return "live";
2366
2380
  if (declined) return "declined";
2367
2381
  return "idle";
2368
2382
  }
2369
- function teardown() {
2370
- if (video) {
2371
- try {
2372
- video.pause();
2373
- } catch {
2374
- }
2375
- video.srcObject = null;
2376
- video.remove();
2377
- }
2378
- if (stream) {
2379
- for (const t of stream.getTracks()) {
2380
- try {
2381
- t.stop();
2382
- } catch {
2383
- }
2384
- }
2385
- }
2386
- video = null;
2387
- stream = null;
2388
- track = null;
2389
- grabber = null;
2390
- mode = "tab";
2391
- mapping = null;
2392
- mappingSignature = "";
2393
- }
2394
- function stopExactCapture() {
2395
- teardown();
2383
+ function getFrozenFrame() {
2384
+ return frozen;
2396
2385
  }
2397
- function resetExactCaptureDecline() {
2398
- declined = false;
2386
+ function releaseFrozenFrame() {
2387
+ if (frozen) {
2388
+ frozen.canvas.width = 0;
2389
+ frozen.canvas.height = 0;
2390
+ }
2391
+ frozen = null;
2399
2392
  }
2400
2393
  function waitFor(test, timeoutMs) {
2401
2394
  if (test()) return Promise.resolve(true);
@@ -2435,17 +2428,28 @@ function nextPresentedFrame(el) {
2435
2428
  });
2436
2429
  });
2437
2430
  }
2438
- function validateFrameMapping(frameW, frameH) {
2431
+ function looksLikeViewport(frameW, frameH) {
2439
2432
  if (!frameW || !frameH) return false;
2440
2433
  const vw = window.innerWidth;
2441
2434
  const vh = window.innerHeight;
2442
2435
  if (!vw || !vh) return false;
2443
- const frameAspect = frameW / frameH;
2444
- const viewAspect = vw / vh;
2445
- return Math.abs(frameAspect - viewAspect) / viewAspect <= ASPECT_TOLERANCE;
2436
+ return Math.abs(frameW / frameH - vw / vh) / (vw / vh) <= ASPECT_TOLERANCE;
2446
2437
  }
2447
- async function grabFrameCanvas() {
2448
- if (!video) return null;
2438
+ async function withOverlayHidden(fn) {
2439
+ const hosts = Array.from(
2440
+ document.querySelectorAll("body > [data-qa-overlay]")
2441
+ );
2442
+ const previous = hosts.map((el) => el.style.visibility);
2443
+ for (const el of hosts) el.style.visibility = "hidden";
2444
+ try {
2445
+ return await fn();
2446
+ } finally {
2447
+ hosts.forEach((el, i) => {
2448
+ el.style.visibility = previous[i];
2449
+ });
2450
+ }
2451
+ }
2452
+ async function grabFrameCanvas(video, grabber) {
2449
2453
  await nextPresentedFrame(video);
2450
2454
  let source = video;
2451
2455
  let w = video.videoWidth;
@@ -2476,11 +2480,10 @@ async function grabFrameCanvas() {
2476
2480
  bitmap?.close?.();
2477
2481
  return c;
2478
2482
  }
2479
- async function calibrate() {
2480
- if (!video) return false;
2483
+ async function calibrate(video, grabber) {
2481
2484
  const vw = window.innerWidth;
2482
2485
  const vh = window.innerHeight;
2483
- if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return false;
2486
+ if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
2484
2487
  const card = document.createElement("div");
2485
2488
  card.setAttribute("data-qa-calibration", "true");
2486
2489
  card.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;pointer-events:none;contain:strict;";
@@ -2494,10 +2497,10 @@ async function calibrate() {
2494
2497
  document.body.appendChild(card);
2495
2498
  try {
2496
2499
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2497
- const frame = await grabFrameCanvas();
2498
- if (!frame) return false;
2500
+ const frame = await grabFrameCanvas(video, grabber);
2501
+ if (!frame) return null;
2499
2502
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2500
- if (!ctx) return false;
2503
+ if (!ctx) return null;
2501
2504
  const { data } = ctx.getImageData(0, 0, frame.width, frame.height);
2502
2505
  const found = deriveMapping(
2503
2506
  findMarkerCandidates(data, frame.width, frame.height),
@@ -2506,20 +2509,40 @@ async function calibrate() {
2506
2509
  frame.width,
2507
2510
  frame.height
2508
2511
  );
2509
- if (!found) return false;
2510
- mapping = found;
2511
- mappingSignature = environmentSignature();
2512
- return true;
2512
+ frame.width = 0;
2513
+ frame.height = 0;
2514
+ return found;
2513
2515
  } catch {
2514
- return false;
2516
+ return null;
2515
2517
  } finally {
2516
2518
  card.remove();
2517
2519
  }
2518
2520
  }
2519
- async function startExactCapture() {
2520
- if (!isExactCaptureSupported()) return false;
2521
- if (track && track.readyState === "live" && video) return true;
2522
- if (stream) teardown();
2521
+ async function freezeViewport() {
2522
+ if (!isExactCaptureSupported()) return null;
2523
+ releaseFrozenFrame();
2524
+ let stream = null;
2525
+ let el = null;
2526
+ const release = () => {
2527
+ if (el) {
2528
+ try {
2529
+ el.pause();
2530
+ } catch {
2531
+ }
2532
+ el.srcObject = null;
2533
+ el.remove();
2534
+ el = null;
2535
+ }
2536
+ if (stream) {
2537
+ for (const t of stream.getTracks()) {
2538
+ try {
2539
+ t.stop();
2540
+ } catch {
2541
+ }
2542
+ }
2543
+ stream = null;
2544
+ }
2545
+ };
2523
2546
  try {
2524
2547
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
2525
2548
  const constraints = {
@@ -2537,121 +2560,103 @@ async function startExactCapture() {
2537
2560
  surfaceSwitching: "exclude",
2538
2561
  systemAudio: "exclude"
2539
2562
  };
2540
- const s = await navigator.mediaDevices.getDisplayMedia(constraints);
2541
- const t = s.getVideoTracks()[0];
2542
- if (!t) {
2543
- for (const x of s.getTracks()) x.stop();
2563
+ stream = await navigator.mediaDevices.getDisplayMedia(constraints);
2564
+ const track = stream.getVideoTracks()[0];
2565
+ if (!track) {
2544
2566
  declined = true;
2545
- return false;
2567
+ return null;
2546
2568
  }
2547
- const settings = t.getSettings();
2569
+ const settings = track.getSettings();
2548
2570
  const sharedTab = !settings.displaySurface || settings.displaySurface === "browser";
2549
- const el = document.createElement("video");
2571
+ el = document.createElement("video");
2550
2572
  el.setAttribute("data-qa-overlay", "true");
2551
2573
  el.muted = true;
2552
2574
  el.playsInline = true;
2553
2575
  el.style.cssText = "position:fixed;top:0;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;";
2554
- el.srcObject = s;
2576
+ el.srcObject = stream;
2555
2577
  document.body.appendChild(el);
2556
2578
  try {
2557
2579
  await el.play();
2558
2580
  } catch {
2559
2581
  }
2560
- const ready = await waitFor(() => el.videoWidth > 0 && el.videoHeight > 0, VIDEO_READY_TIMEOUT_MS);
2561
- const giveUp = () => {
2562
- el.remove();
2563
- for (const x of s.getTracks()) x.stop();
2582
+ const video = el;
2583
+ const ready = await waitFor(
2584
+ () => video.videoWidth > 0 && video.videoHeight > 0,
2585
+ VIDEO_READY_TIMEOUT_MS
2586
+ );
2587
+ if (!ready) {
2564
2588
  declined = true;
2565
- return false;
2566
- };
2567
- if (!ready) return giveUp();
2568
- const isDirect = sharedTab && validateFrameMapping(el.videoWidth, el.videoHeight);
2569
- stream = s;
2570
- track = t;
2571
- video = el;
2589
+ return null;
2590
+ }
2572
2591
  const IC = window.ImageCapture;
2573
- grabber = IC ? new IC(t) : null;
2574
- if (isDirect) {
2575
- mode = "tab";
2576
- mapping = null;
2577
- mappingSignature = "";
2578
- } else {
2579
- mode = "surface";
2580
- if (!await calibrate()) {
2581
- teardown();
2582
- declined = true;
2583
- return false;
2584
- }
2592
+ const grabber = IC ? new IC(track) : null;
2593
+ const vw = window.innerWidth;
2594
+ const vh = window.innerHeight;
2595
+ const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2596
+ const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2597
+ if (mode === "surface" && !mapping) {
2598
+ declined = true;
2599
+ return null;
2585
2600
  }
2586
- t.addEventListener("ended", () => {
2587
- teardown();
2588
- });
2589
- return true;
2601
+ const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2602
+ if (!raw) return null;
2603
+ let page;
2604
+ if (mode === "surface" && mapping) {
2605
+ const box = mapRectToFrame(
2606
+ { left: 0, top: 0, width: vw, height: vh },
2607
+ mapping,
2608
+ raw.width,
2609
+ raw.height
2610
+ );
2611
+ if (!box) return null;
2612
+ page = document.createElement("canvas");
2613
+ page.width = box.sw;
2614
+ page.height = box.sh;
2615
+ const ctx = page.getContext("2d");
2616
+ if (!ctx) return null;
2617
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2618
+ raw.width = 0;
2619
+ raw.height = 0;
2620
+ } else {
2621
+ page = raw;
2622
+ }
2623
+ frozen = {
2624
+ canvas: page,
2625
+ mode,
2626
+ viewportWidth: vw,
2627
+ viewportHeight: vh,
2628
+ takenAt: Date.now()
2629
+ };
2630
+ lastMode = mode;
2631
+ return frozen;
2590
2632
  } catch {
2591
2633
  declined = true;
2592
- teardown();
2593
- return false;
2634
+ return null;
2635
+ } finally {
2636
+ release();
2594
2637
  }
2595
2638
  }
2596
- async function grabExactRegion(rect) {
2597
- if (!track || track.readyState !== "live" || !video) return null;
2598
- if (mode === "surface") {
2599
- if (!mapping || environmentSignature() !== mappingSignature) {
2600
- if (!await calibrate()) return null;
2601
- }
2602
- }
2603
- await nextPresentedFrame(video);
2604
- let source;
2605
- let frameW;
2606
- let frameH;
2607
- let bitmap = null;
2608
- if (grabber) {
2609
- try {
2610
- bitmap = await grabber.grabFrame();
2611
- source = bitmap;
2612
- frameW = bitmap.width;
2613
- frameH = bitmap.height;
2614
- } catch {
2615
- source = video;
2616
- frameW = video.videoWidth;
2617
- frameH = video.videoHeight;
2618
- }
2619
- } else {
2620
- source = video;
2621
- frameW = video.videoWidth;
2622
- frameH = video.videoHeight;
2623
- }
2624
- try {
2625
- let sx;
2626
- let sy;
2627
- let sw;
2628
- let sh;
2629
- if (mode === "surface") {
2630
- if (!mapping) return null;
2631
- const mapped = mapRectToFrame(rect, mapping, frameW, frameH);
2632
- if (!mapped) return null;
2633
- ({ sx, sy, sw, sh } = mapped);
2634
- } else {
2635
- if (!validateFrameMapping(frameW, frameH)) return null;
2636
- const scaleX = frameW / window.innerWidth;
2637
- const scaleY = frameH / window.innerHeight;
2638
- sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2639
- sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2640
- sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2641
- sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2642
- }
2643
- const canvas = document.createElement("canvas");
2644
- canvas.width = sw;
2645
- canvas.height = sh;
2646
- const ctx = canvas.getContext("2d");
2647
- if (!ctx) return null;
2648
- ctx.drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
2649
- return canvas;
2650
- } catch {
2639
+ function cropFrozenRegion(rect) {
2640
+ const f = frozen;
2641
+ if (!f || !f.canvas.width || !f.canvas.height) return null;
2642
+ if (!f.viewportWidth || !f.viewportHeight) return null;
2643
+ if (window.innerWidth !== f.viewportWidth || window.innerHeight !== f.viewportHeight) {
2651
2644
  return null;
2652
- } finally {
2653
- if (bitmap && typeof bitmap.close === "function") bitmap.close();
2654
2645
  }
2646
+ const scaleX = f.canvas.width / f.viewportWidth;
2647
+ const scaleY = f.canvas.height / f.viewportHeight;
2648
+ const sx = Math.max(0, Math.min(f.canvas.width, Math.round(rect.left * scaleX)));
2649
+ const sy = Math.max(0, Math.min(f.canvas.height, Math.round(rect.top * scaleY)));
2650
+ const sw = Math.min(f.canvas.width - sx, Math.round(rect.width * scaleX));
2651
+ const sh = Math.min(f.canvas.height - sy, Math.round(rect.height * scaleY));
2652
+ if (sw < 1 || sh < 1) return null;
2653
+ const out = document.createElement("canvas");
2654
+ out.width = sw;
2655
+ out.height = sh;
2656
+ const ctx = out.getContext("2d");
2657
+ if (!ctx) return null;
2658
+ ctx.drawImage(f.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
2659
+ return out;
2655
2660
  }
2656
2661
 
2657
2662
  // src/lib/cssColors.ts
@@ -2795,12 +2800,21 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2795
2800
 
2796
2801
  // src/lib/capture.ts
2797
2802
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2803
+ var CHUNK_TIMEOUT_MS = 8e3;
2798
2804
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2799
2805
  var MAX_SHOT_EDGE = 1800;
2800
2806
  var WEBP_QUALITY = 0.92;
2801
2807
  var STUCK_ATTR = "data-qa-stuck";
2802
2808
  var MAX_CAPTURE_EDGE = 4e3;
2803
2809
  var MAX_RENDER_DEVICE_PIXELS = 16e6;
2810
+ var TOO_HEAVY_NODES = 6e3;
2811
+ function documentWeight() {
2812
+ try {
2813
+ return document.getElementsByTagName("*").length;
2814
+ } catch {
2815
+ return 0;
2816
+ }
2817
+ }
2804
2818
  function withTimeout(promise, ms) {
2805
2819
  let timer;
2806
2820
  return Promise.race([
@@ -2862,8 +2876,13 @@ function fitToBudget(canvas) {
2862
2876
  function canvasToBlob(canvas, type, quality) {
2863
2877
  return new Promise((resolve) => {
2864
2878
  if (canvas.toBlob) {
2865
- canvas.toBlob((b) => resolve(b), type, quality);
2866
- return;
2879
+ try {
2880
+ canvas.toBlob((b) => resolve(b), type, quality);
2881
+ return;
2882
+ } catch {
2883
+ resolve(null);
2884
+ return;
2885
+ }
2867
2886
  }
2868
2887
  try {
2869
2888
  const dataUrl = canvas.toDataURL(type, quality);
@@ -2890,20 +2909,6 @@ function shotExtension(blob) {
2890
2909
  if (type === "image/jpeg") return "jpg";
2891
2910
  return "png";
2892
2911
  }
2893
- async function withOverlayHidden(fn) {
2894
- const hosts = Array.from(
2895
- document.querySelectorAll("body > [data-qa-overlay]")
2896
- );
2897
- const previous = hosts.map((el) => el.style.visibility);
2898
- for (const el of hosts) el.style.visibility = "hidden";
2899
- try {
2900
- return await fn();
2901
- } finally {
2902
- hosts.forEach((el, i) => {
2903
- el.style.visibility = previous[i];
2904
- });
2905
- }
2906
- }
2907
2912
  function markStuckElements() {
2908
2913
  let all;
2909
2914
  try {
@@ -3064,7 +3069,9 @@ function planRender(rect, sx, sy, vw, vh) {
3064
3069
  };
3065
3070
  }
3066
3071
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3067
- const { default: html2canvas } = await import('html2canvas');
3072
+ const mod = await withTimeout(import('html2canvas'), CHUNK_TIMEOUT_MS);
3073
+ if (!mod) return null;
3074
+ const { default: html2canvas } = mod;
3068
3075
  const { vw, vh } = viewportSize();
3069
3076
  const plan = planRender(rect, sx, sy, vw, vh);
3070
3077
  let scale = Math.min(window.devicePixelRatio || 1, 2);
@@ -3083,7 +3090,22 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3083
3090
  height: plan.render.height,
3084
3091
  scale,
3085
3092
  useCORS: true,
3086
- allowTaint: true,
3093
+ // MUST stay false. `allowTaint: true` tells html2canvas to draw
3094
+ // cross-origin images it could not fetch with CORS -- which succeeds,
3095
+ // and TAINTS the canvas. A tainted canvas throws SecurityError from
3096
+ // toBlob/toDataURL, so the render finishes and then the encode dies,
3097
+ // and the tester gets "couldn't capture" with nothing to look at.
3098
+ //
3099
+ // That is one image away on any real page: a map tile, a news
3100
+ // thumbnail, an avatar, an ad, a CDN logo. It made image-heavy pages
3101
+ // fail 100% of the time while a plain page worked, which is exactly
3102
+ // the "it works for you and not for me" this kept producing.
3103
+ //
3104
+ // False is strictly better: html2canvas SKIPS the images it cannot
3105
+ // read, so those come out blank and everything else comes out right.
3106
+ // A screenshot with two grey rectangles in it is worth having. A
3107
+ // SecurityError is not.
3108
+ allowTaint: false,
3087
3109
  // The page's own background, never null — see resolvePageBackground().
3088
3110
  // Passed straight to html2canvas's parser, so it gets the same
3089
3111
  // CSS Color 4 treatment the document walk applies.
@@ -3118,20 +3140,24 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3118
3140
  if (!rect || rect.width < 2 || rect.height < 2) return { status: "empty" };
3119
3141
  const sx = scroll?.x ?? window.scrollX;
3120
3142
  const sy = scroll?.y ?? window.scrollY;
3121
- if (prefer !== "dom" && getExactCaptureStatus() === "live") {
3143
+ if (prefer !== "dom" && getFrozenFrame()) {
3122
3144
  const visible = intersectViewport(rect);
3123
3145
  if (!visible) return { status: "empty" };
3124
3146
  try {
3125
- const canvas = await withOverlayHidden(() => grabExactRegion(visible));
3147
+ const canvas = cropFrozenRegion(visible);
3126
3148
  if (canvas) {
3127
3149
  const blob = await encodeShot(canvas);
3128
3150
  if (blob) return { status: "ok", blob, engine: "exact" };
3129
3151
  }
3130
3152
  } catch (err) {
3131
- console.warn("[QA] exact capture failed, falling back to DOM render:", err);
3153
+ console.warn("[QA] cropping the still failed, falling back to DOM render:", err);
3132
3154
  }
3133
3155
  if (prefer === "exact") return { status: "failed" };
3134
3156
  }
3157
+ const weight = documentWeight();
3158
+ if (weight > TOO_HEAVY_NODES) {
3159
+ return { status: "too-heavy", nodes: weight };
3160
+ }
3135
3161
  try {
3136
3162
  const canvas = await captureViaDom(rect, sx, sy);
3137
3163
  if (!canvas) return { status: "failed" };
@@ -3189,6 +3215,12 @@ function formatEvent(ev, t0) {
3189
3215
  }
3190
3216
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3191
3217
  }
3218
+ function noteCheckLine(note, index) {
3219
+ const where = oneLine(note.route) || "/";
3220
+ const what = oneLine(note.description) || "(no description)";
3221
+ const trimmed = what.length > 160 ? `${what.slice(0, 157)}...` : what;
3222
+ return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3223
+ }
3192
3224
  function noteToMarkdown(note, opts) {
3193
3225
  const brand = opts?.brand ?? "Qapture";
3194
3226
  const idx = opts?.index;
@@ -3246,6 +3278,14 @@ function noteToMarkdown(note, opts) {
3246
3278
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3247
3279
  });
3248
3280
  }
3281
+ if (idx != null) {
3282
+ lines.push("");
3283
+ lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3284
+ lines.push("");
3285
+ lines.push(`The tester will be taken back to \`${oneLine(note.route) || "/"}\`` + (target?.selector ? `, shown \`${oneLine(target.selector)}\`` : ", shown this region") + ", and asked: *is this now what I asked for?*");
3286
+ lines.push("");
3287
+ lines.push(`Treat it as done only when the paragraph above is true on that page, on a fresh load, without the tester doing anything extra. Record the outcome against \`check-${idx}\` in \`verify.md\`.`);
3288
+ }
3249
3289
  const ctx = note.context;
3250
3290
  if (ctx) {
3251
3291
  const env = ctx.env;
@@ -3418,6 +3458,17 @@ Second, **the previous attempt is evidence.** Something about the first reading
3418
3458
  The tag sets your starting posture; **the words win.** A point tagged Design that says "this crashes" is a bug \u2014 treat it as one, and mention that you changed lane. People pick the wrong chip all the time; that is not a reason to do the wrong work.
3419
3459
 
3420
3460
  And if a point is phrased as a question rather than a report, **answer it \u2014 do not change code on a maybe.**`
3461
+ );
3462
+ sections.push(
3463
+ `## You Are Being Graded On This
3464
+
3465
+ This archive is not a list of suggestions. Every point in \`notes.md\` is an acceptance test, and \`verify.md\` is the checklist those tests live in \u2014 one unticked box per point.
3466
+
3467
+ **What happens next.** The tester will be walked back through these same checks on the real page, in this order, one stop at a time, and asked about each: *is this now what I asked for?* Anything they say is still wrong comes back to you as round two with their new words attached, so nothing quietly disappears.
3468
+
3469
+ **What to do.** Do the work, then fill in \`verify.md\`: tick a box only when the check is true on a fresh load of the page named beside it, with the tester doing nothing extra. Where you could not do something, or you think it is the wrong thing to do, leave the box unticked and write one line saying why. An unticked box with a reason is a good answer. A ticked box that does not hold up is the only bad one \u2014 it costs the tester the trip to find out.
3470
+
3471
+ Hand \`verify.md\` back with the work.`
3421
3472
  );
3422
3473
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3423
3474
  const runArr = toStrings(p.runCommands);
@@ -3565,6 +3616,39 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3565
3616
  ` : "";
3566
3617
  const notesMd = preambleMd + "\n\n---NOTES---\n\n" + notesHeader + notesBody;
3567
3618
  zip.file("notes.md", notesMd);
3619
+ zip.file("verify.md", [
3620
+ `# ${brandLabel} \u2014 checks to satisfy`,
3621
+ "",
3622
+ `Exported: ${stamp}`,
3623
+ `Checks: ${notes.length}`,
3624
+ "",
3625
+ "Each line below is one thing the tester asked for. The full ask, the",
3626
+ "screenshot, the page and the element are in `notes.md` under the matching",
3627
+ '"Point" heading.',
3628
+ "",
3629
+ "**What is expected of you**",
3630
+ "",
3631
+ "1. Do the work in `notes.md`.",
3632
+ "2. Tick a box here only when the check is true on a fresh load of the page",
3633
+ " named beside it, with the tester doing nothing extra.",
3634
+ "3. Leave a box unticked and write one line under it saying why, if you",
3635
+ " could not do it or disagree with it. An unticked box with a reason is a",
3636
+ " good answer; a ticked box that is not true is the only bad one.",
3637
+ "4. Hand this file back with the work.",
3638
+ "",
3639
+ "The tester will then be walked back through these same checks on the real",
3640
+ "page, in this order, and asked about each one. Anything they say is still",
3641
+ "wrong comes back to you as round two, with their new words attached.",
3642
+ "",
3643
+ "---",
3644
+ "",
3645
+ ...notes.map((n, i) => noteCheckLine(n, i + 1)),
3646
+ "",
3647
+ "---",
3648
+ "",
3649
+ "Re-open the walkthrough on the tester's machine with `?qa=walk:verify`.",
3650
+ ""
3651
+ ].join("\n"));
3568
3652
  notes.forEach((n, i) => {
3569
3653
  if (n.screenshot && shots) {
3570
3654
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4204,6 +4288,9 @@ var GUIDE_FAILED_KEY = "guideFailed";
4204
4288
  var LOGIN_KEY = "logins";
4205
4289
  var PENDING_DELETE_KEY = "pendingDeleteIds";
4206
4290
  var EXACT_SHOTS_KEY = "exactShots";
4291
+ function exactShotsWanted(store) {
4292
+ return store.getItem(EXACT_SHOTS_KEY) !== "0";
4293
+ }
4207
4294
  var SIMPLE_MODE_KEY = "simpleMode";
4208
4295
  var COMPACT_KEY = "compactCapture";
4209
4296
  var LAST_CAMPAIGN_KEY = "lastCampaign";
@@ -4218,11 +4305,12 @@ var PANEL_OPEN_KEY = "panelOpen";
4218
4305
  var ACTIVE_TAB_KEY = "activeTab";
4219
4306
  var WALK_KEY = "walk";
4220
4307
  var SOFT_NAV_PROOF_MS = 700;
4308
+ var SIGNATURE_SEP = String.fromCharCode(0);
4221
4309
  function pageSignature() {
4222
4310
  if (typeof document === "undefined" || !document.body) return "";
4223
4311
  const body = document.body;
4224
4312
  const text = body.textContent || "";
4225
- return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join("\0");
4313
+ return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join(SIGNATURE_SEP);
4226
4314
  }
4227
4315
  var GUIDE_SKIPPED_KEY = "guideSkipped";
4228
4316
  var NOTICE_QUEUE_CAP = 3;
@@ -4280,8 +4368,12 @@ function QaProvider({
4280
4368
  const [loginsUsed, setLoginsUsed] = useState(
4281
4369
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4282
4370
  );
4283
- const [exactStatus, setExactStatus] = useState(() => getExactCaptureStatus());
4371
+ const [exactStatus, setExactStatus] = useState(() => {
4372
+ if (exactShotsWanted(storage)) armExactCapture();
4373
+ return getExactCaptureStatus();
4374
+ });
4284
4375
  const exactSupported = isExactCaptureSupported();
4376
+ const [frozenAt, setFrozenAt] = useState(null);
4285
4377
  const [syncState, setSyncState] = useState(() => getFsSyncState());
4286
4378
  const [syncTick, setSyncTick] = useState(0);
4287
4379
  const [lastCampaign, setLastCampaign] = useState(
@@ -4782,20 +4874,26 @@ function QaProvider({
4782
4874
  flushPendingDeletes();
4783
4875
  for (const timer of noticeTimers.current.values()) clearTimeout(timer);
4784
4876
  noticeTimers.current.clear();
4785
- stopExactCapture();
4877
+ releaseFrozenFrame();
4786
4878
  };
4787
4879
  }, [flushPendingDeletes]);
4788
4880
  const startCapture = useCallback((prefill) => {
4789
4881
  setIsOpen(false);
4790
4882
  setCapturePrefill(prefill ?? "");
4791
4883
  setCaptureActive(true);
4792
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported() && getExactCaptureStatus() !== "live") {
4884
+ setFrozenAt(null);
4885
+ if (exactShotsWanted(storage) && isExactCaptureSupported()) {
4793
4886
  resetExactCaptureDecline();
4794
- void startExactCapture().then(() => setExactStatus(getExactCaptureStatus()));
4887
+ void freezeViewport().then((frame) => {
4888
+ setFrozenAt(frame?.takenAt ?? null);
4889
+ setExactStatus(getExactCaptureStatus());
4890
+ });
4795
4891
  }
4796
4892
  }, [storage]);
4797
4893
  const endCapture = useCallback((reopen = true) => {
4798
4894
  setCaptureActive(false);
4895
+ releaseFrozenFrame();
4896
+ setFrozenAt(null);
4799
4897
  if (reopen) setIsOpen(true);
4800
4898
  }, []);
4801
4899
  const toggleGuide = useCallback((key) => {
@@ -4816,19 +4914,36 @@ function QaProvider({
4816
4914
  return next;
4817
4915
  });
4818
4916
  }, [storage]);
4917
+ const stampHandedOff = useCallback(async (ids, at) => {
4918
+ if (!ids.length) return;
4919
+ const idSet = new Set(ids);
4920
+ const touched = [];
4921
+ applyNotes(
4922
+ (prev) => prev.map((n) => {
4923
+ if (!idSet.has(n.id)) return n;
4924
+ if ((n.status ?? "open") === "verified") return n;
4925
+ const next = { ...n, handedOffAt: at };
4926
+ touched.push(next);
4927
+ return next;
4928
+ })
4929
+ );
4930
+ for (const note of touched) await idb.put(note);
4931
+ }, [applyNotes, idb]);
4819
4932
  const exportZipFn = useCallback(
4820
4933
  async (filename) => {
4821
4934
  if (!notes.length || isExporting) return;
4822
4935
  setIsExporting(true);
4823
4936
  try {
4824
- await buildAndDownloadZip(notes, nowIso(), filename, config, guideChecked, guideSkipped);
4937
+ const stamp = nowIso();
4938
+ await buildAndDownloadZip(notes, stamp, filename, config, guideChecked, guideSkipped);
4939
+ await stampHandedOff(notes.map((n) => n.id), stamp);
4825
4940
  } catch (err) {
4826
4941
  console.error("[QA] export failed", err);
4827
4942
  } finally {
4828
4943
  setIsExporting(false);
4829
4944
  }
4830
4945
  },
4831
- [notes, isExporting, config, guideChecked, guideSkipped]
4946
+ [notes, isExporting, config, guideChecked, guideSkipped, stampHandedOff]
4832
4947
  );
4833
4948
  const exportZipRef = useRef(async () => {
4834
4949
  });
@@ -4836,16 +4951,32 @@ function QaProvider({
4836
4951
  exportZipRef.current = exportZipFn;
4837
4952
  }, [exportZipFn]);
4838
4953
  const enableExactShots = useCallback(async () => {
4839
- resetExactCaptureDecline();
4840
- const ok = await startExactCapture();
4954
+ const ok = armExactCapture();
4841
4955
  setExactStatus(getExactCaptureStatus());
4842
4956
  storage.setItem(EXACT_SHOTS_KEY, ok ? "1" : "0");
4843
4957
  notify(ok ? t("exact_on") : t("exact_declined"), { tone: ok ? "success" : "info", id: "exact-shots" });
4844
4958
  return ok;
4845
4959
  }, [storage, notify, t]);
4960
+ const photographNow = useCallback(async () => {
4961
+ if (!armExactCapture()) {
4962
+ setExactStatus(getExactCaptureStatus());
4963
+ notify(t("exact_declined"), { tone: "info", id: "exact-shots" });
4964
+ return false;
4965
+ }
4966
+ storage.setItem(EXACT_SHOTS_KEY, "1");
4967
+ const frame = await freezeViewport();
4968
+ setFrozenAt(frame?.takenAt ?? null);
4969
+ setExactStatus(getExactCaptureStatus());
4970
+ notify(frame ? t("exact_on") : t("exact_declined"), {
4971
+ tone: frame ? "success" : "info",
4972
+ id: "exact-shots"
4973
+ });
4974
+ return !!frame;
4975
+ }, [storage, notify, t]);
4846
4976
  const disableExactShots = useCallback(() => {
4847
- stopExactCapture();
4977
+ disarmExactCapture();
4848
4978
  storage.setItem(EXACT_SHOTS_KEY, "0");
4979
+ setFrozenAt(null);
4849
4980
  setExactStatus(getExactCaptureStatus());
4850
4981
  }, [storage]);
4851
4982
  const rememberCampaign = useCallback((next) => {
@@ -4998,6 +5129,7 @@ function QaProvider({
4998
5129
  design: 0,
4999
5130
  enhance: 0,
5000
5131
  open: 0,
5132
+ sent: 0,
5001
5133
  fixed: 0,
5002
5134
  verified: 0,
5003
5135
  thisPage: 0
@@ -5011,6 +5143,7 @@ function QaProvider({
5011
5143
  if (status === "verified") counts.verified++;
5012
5144
  else if (status === "fixed") counts.fixed++;
5013
5145
  else counts.open++;
5146
+ if (n.handedOffAt && status !== "verified") counts.sent++;
5014
5147
  if (n.route.split("?")[0] === currentRoute) counts.thisPage++;
5015
5148
  }
5016
5149
  return counts;
@@ -5019,7 +5152,9 @@ function QaProvider({
5019
5152
  const q = filter.query.trim().toLowerCase();
5020
5153
  return notes.filter((n) => {
5021
5154
  if (filter.severity !== "all" && (n.severity ?? "bug") !== filter.severity) return false;
5022
- if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5155
+ if (filter.status === "sent") {
5156
+ if (!n.handedOffAt || (n.status ?? "open") === "verified") return false;
5157
+ } else if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5023
5158
  if (filter.thisPageOnly && n.route.split("?")[0] !== currentRoute) return false;
5024
5159
  if (!q) return true;
5025
5160
  const haystack = `${n.description} ${n.route} ${n.target?.selector ?? ""} ${n.target?.text ?? ""}`;
@@ -5064,6 +5199,7 @@ function QaProvider({
5064
5199
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
5065
5200
  if (!blob) return { status: "unsupported" };
5066
5201
  const outcome = await shareZipFile(blob, name, config.brand.label);
5202
+ await stampHandedOff(notes.map((n) => n.id), stamp);
5067
5203
  if (outcome.status === "needs-gesture") {
5068
5204
  setPendingShare({ blob, filename: name });
5069
5205
  } else if (outcome.status === "unsupported") {
@@ -5075,7 +5211,7 @@ function QaProvider({
5075
5211
  } finally {
5076
5212
  setIsExporting(false);
5077
5213
  }
5078
- }, [notes, config, guideChecked, guideSkipped]);
5214
+ }, [notes, config, guideChecked, guideSkipped, stampHandedOff]);
5079
5215
  const sharePending = useCallback(async () => {
5080
5216
  const pending = pendingShare;
5081
5217
  if (!pending) return { status: "unsupported" };
@@ -5291,7 +5427,10 @@ function QaProvider({
5291
5427
  window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
5292
5428
  } catch {
5293
5429
  }
5294
- if (which === "retest") {
5430
+ if (which === "verify") {
5431
+ setFilterState((prev) => ({ ...prev, status: "sent", severity: "all", thisPageOnly: false }));
5432
+ startWalk("notes", 0);
5433
+ } else if (which === "retest") {
5295
5434
  setFilterState((prev) => ({ ...prev, status: "fixed", severity: "all", thisPageOnly: false }));
5296
5435
  startWalk("notes", 0);
5297
5436
  } else if (which === "notes") {
@@ -5364,8 +5503,9 @@ function QaProvider({
5364
5503
  gradeStep,
5365
5504
  evidenceByStep,
5366
5505
  // v0.4 — screenshots
5367
- exactShots: { supported: exactSupported, status: exactStatus },
5506
+ exactShots: { supported: exactSupported, status: exactStatus, frozenAt },
5368
5507
  enableExactShots,
5508
+ photographNow,
5369
5509
  disableExactShots,
5370
5510
  // v0.4 — folder sync. syncTick is read here purely so this object is
5371
5511
  // rebuilt when fsSync's module state changes (path/campaign live outside
@@ -7348,7 +7488,7 @@ function FilterChip({ chip }) {
7348
7488
  );
7349
7489
  }
7350
7490
  function NoteFilterBar() {
7351
- const { filter, setFilter, noteCounts, notes, deleteNotes, t } = useQa();
7491
+ const { filter, setFilter, noteCounts, notes, deleteNotes, startWalk, t } = useQa();
7352
7492
  const [confirmPrune, setConfirmPrune] = useState(false);
7353
7493
  if (notes.length < 3) return null;
7354
7494
  const others = notes.filter((n) => (n.status ?? "open") !== "fixed");
@@ -7390,6 +7530,18 @@ function NoteFilterBar() {
7390
7530
  tone: "accent",
7391
7531
  onClick: sev("design")
7392
7532
  },
7533
+ // Only ever shown once something has actually been sent. A chip reading
7534
+ // "Sent 0" on a first run is noise; the same chip reading "Sent 6" the
7535
+ // morning after an export is the one thing the tester came back for.
7536
+ ...noteCounts.sent > 0 ? [{
7537
+ key: "sent",
7538
+ label: t("status_sent"),
7539
+ count: noteCounts.sent,
7540
+ active: filter.status === "sent",
7541
+ icon: "Camera",
7542
+ tone: "accent",
7543
+ onClick: () => setFilter({ status: "sent", severity: "all", thisPageOnly: false })
7544
+ }] : [],
7393
7545
  {
7394
7546
  key: "open",
7395
7547
  label: t("status_open"),
@@ -7458,6 +7610,24 @@ function NoteFilterBar() {
7458
7610
  }
7459
7611
  ),
7460
7612
  /* @__PURE__ */ jsx("div", { className: "qa-flex qa-flex-wrap qa-gap-1", children: chips.map((chip) => /* @__PURE__ */ jsx(FilterChip, { chip }, chip.key)) }),
7613
+ noteCounts.sent > 0 && /* @__PURE__ */ jsxs(
7614
+ "button",
7615
+ {
7616
+ type: "button",
7617
+ "data-qa-verify-walk": "true",
7618
+ onClick: () => {
7619
+ setFilter({ status: "sent", severity: "all", thisPageOnly: false });
7620
+ startWalk("notes", 0);
7621
+ },
7622
+ title: t("check_fixes_hint"),
7623
+ className: "qa-tap qa-inline-flex qa-items-center qa-justify-center qa-gap-1.5 qa-rounded-md qa-border qa-border-subtle qa-px-2 qa-py-1.5 qa-text-xs qa-text-hi qa-focus-ring",
7624
+ style: { background: "transparent", cursor: "pointer" },
7625
+ children: [
7626
+ /* @__PURE__ */ jsx(Icon, { name: "Check", size: 13 }),
7627
+ t("check_fixes", { n: noteCounts.sent })
7628
+ ]
7629
+ }
7630
+ ),
7461
7631
  showPrune && /* @__PURE__ */ jsx("div", { className: "qa-text-11", "data-qa-prune": "true", children: confirmPrune ? /* @__PURE__ */ jsxs("span", { className: "qa-text-mid", children: [
7462
7632
  t("keep_only_retest_q", { n: others.length }),
7463
7633
  " ",
@@ -9198,7 +9368,7 @@ function CaptureMode() {
9198
9368
  compactCapture,
9199
9369
  setCompactCapture,
9200
9370
  exactShots,
9201
- enableExactShots,
9371
+ photographNow,
9202
9372
  capturePrefill
9203
9373
  } = useQa();
9204
9374
  const coarse = useCoarsePointer();
@@ -9212,10 +9382,13 @@ function CaptureMode() {
9212
9382
  const [candidate, setCandidate] = useState(null);
9213
9383
  const [regionMode, setRegionMode] = useState(false);
9214
9384
  const [shot, setShot] = useState(null);
9385
+ const stillRef = useRef(null);
9386
+ const [notesFromThisShot, setNotesFromThisShot] = useState(0);
9215
9387
  const [shotEngine, setShotEngine] = useState(null);
9216
9388
  const [shotUrl, setShotUrl] = useState(null);
9217
9389
  const [capturing, setCapturing] = useState(false);
9218
9390
  const [captureError, setCaptureError] = useState(false);
9391
+ const [tooHeavy, setTooHeavy] = useState(false);
9219
9392
  const [description, setDescription] = useState(capturePrefill);
9220
9393
  const [severity, setSeverity] = useState("bug");
9221
9394
  const [targetForensics, setTargetForensics] = useState(void 0);
@@ -9245,6 +9418,7 @@ function CaptureMode() {
9245
9418
  const runCapture = useCallback(async (rect) => {
9246
9419
  setCapturing(true);
9247
9420
  setCaptureError(false);
9421
+ setTooHeavy(false);
9248
9422
  lockPageScroll();
9249
9423
  try {
9250
9424
  const outcome = await captureRegion(rect, scrollSnap.current);
@@ -9255,6 +9429,7 @@ function CaptureMode() {
9255
9429
  return;
9256
9430
  }
9257
9431
  setCaptureError(outcome.status === "failed");
9432
+ setTooHeavy(outcome.status === "too-heavy");
9258
9433
  setShotEngine(outcome.status === "ok" ? outcome.engine : null);
9259
9434
  setShot(blob);
9260
9435
  setShotUrl((old) => {
@@ -9504,7 +9679,29 @@ function CaptureMode() {
9504
9679
  useEffect(() => () => {
9505
9680
  unlockPageScroll();
9506
9681
  }, []);
9507
- const save = async () => {
9682
+ useEffect(() => {
9683
+ const host = stillRef.current;
9684
+ const frame = getFrozenFrame();
9685
+ if (!host || !frame) return;
9686
+ const canvas = frame.canvas;
9687
+ canvas.style.cssText = "display:block;width:100%;height:100%;";
9688
+ host.appendChild(canvas);
9689
+ return () => {
9690
+ if (canvas.parentNode === host) host.removeChild(canvas);
9691
+ canvas.removeAttribute("style");
9692
+ };
9693
+ }, [exactShots.frozenAt]);
9694
+ const resetForNextNote = useCallback(() => {
9695
+ setPhase("selecting");
9696
+ setSelection(null);
9697
+ setShot(null);
9698
+ setShotEngine(null);
9699
+ setDescription("");
9700
+ setSeverity("bug");
9701
+ setTargetForensics(void 0);
9702
+ setCaptureError(false);
9703
+ }, []);
9704
+ const save = async (keepGoing = false) => {
9508
9705
  if (!selection || !description.trim()) return;
9509
9706
  const target = {
9510
9707
  kind: selection.kind,
@@ -9526,6 +9723,11 @@ function CaptureMode() {
9526
9723
  severity,
9527
9724
  forensics: selection.kind === "element" ? targetForensics : void 0
9528
9725
  });
9726
+ if (keepGoing) {
9727
+ setNotesFromThisShot((n) => n + 1);
9728
+ resetForNextNote();
9729
+ return;
9730
+ }
9529
9731
  endCapture();
9530
9732
  };
9531
9733
  const popStyleFor = useCallback((r, cardWidth = 340) => {
@@ -9562,6 +9764,16 @@ function CaptureMode() {
9562
9764
  const isRegion = !!drag?.rect || candidate?.kind === "region" || selection?.kind === "region";
9563
9765
  const confirmingRegion = phase === "confirming" && candidate?.kind === "region" && coarse;
9564
9766
  return /* @__PURE__ */ jsxs("div", { "data-qa-overlay": "true", "data-qa-capture-root": "true", ref: overlayRootRef, children: [
9767
+ exactShots.frozenAt !== null && /* @__PURE__ */ jsx(
9768
+ "div",
9769
+ {
9770
+ ref: stillRef,
9771
+ "aria-hidden": "true",
9772
+ "data-qa-capture-still": "true",
9773
+ className: "qa-fixed qa-inset-0 qa-z-10089",
9774
+ style: { pointerEvents: "none", overflow: "hidden" }
9775
+ }
9776
+ ),
9565
9777
  /* @__PURE__ */ jsx(
9566
9778
  "div",
9567
9779
  {
@@ -9583,6 +9795,21 @@ function CaptureMode() {
9583
9795
  {
9584
9796
  className: "qa-fixed qa-left-half qa-top-4 qa-z-10095 qa-translate-x-neg-half qa-flex qa-items-center qa-gap-3 qa-rounded-full qa-border qa-border-subtle qa-bg-2 qa-px-4 qa-py-2 qa-text-sm qa-text-hi qa-elev-2",
9585
9797
  children: [
9798
+ notesFromThisShot > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
9799
+ /* @__PURE__ */ jsxs(
9800
+ "span",
9801
+ {
9802
+ "data-qa-shot-reused": "true",
9803
+ className: "qa-flex qa-items-center qa-gap-1.5",
9804
+ style: { color: "var(--qa-success)" },
9805
+ children: [
9806
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 16 }),
9807
+ t("shot_reused", { n: String(notesFromThisShot) })
9808
+ ]
9809
+ }
9810
+ ),
9811
+ /* @__PURE__ */ jsx("span", { className: "qa-opacity-50", children: "\xB7" })
9812
+ ] }),
9586
9813
  /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-1.5", children: [
9587
9814
  /* @__PURE__ */ jsx(Icon, { name: "MousePointerClick", size: 16 }),
9588
9815
  t("cap_click")
@@ -9620,7 +9847,7 @@ function CaptureMode() {
9620
9847
  ) : /* @__PURE__ */ jsxs(
9621
9848
  "button",
9622
9849
  {
9623
- onClick: () => void enableExactShots(),
9850
+ onClick: () => void photographNow(),
9624
9851
  title: t("exact_hint"),
9625
9852
  className: "qa-tap qa-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-white-40 qa-px-2 qa-py-0.5 qa-text-11 qa-text-hi qa-hover-bg-white-15",
9626
9853
  style: { background: "transparent", cursor: "pointer" },
@@ -10032,6 +10259,29 @@ function CaptureMode() {
10032
10259
  )
10033
10260
  ]
10034
10261
  }
10262
+ ) : tooHeavy ? (
10263
+ // Not a failure to report — a refusal to explain, with the one
10264
+ // control that actually solves it.
10265
+ /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3 qa-px-3 qa-text-center", children: [
10266
+ /* @__PURE__ */ jsx("span", { className: "qa-text-xs qa-text-mid", children: t("too_heavy") }),
10267
+ exactShots.supported ? /* @__PURE__ */ jsxs(
10268
+ "button",
10269
+ {
10270
+ type: "button",
10271
+ onClick: () => void (async () => {
10272
+ const shot2 = await photographNow();
10273
+ if (shot2 && selection) void runCapture(selection.rect);
10274
+ })(),
10275
+ title: t("exact_hint"),
10276
+ 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-hi qa-focus-ring",
10277
+ style: { background: "transparent", cursor: "pointer" },
10278
+ children: [
10279
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 13 }),
10280
+ t("redraw_action")
10281
+ ]
10282
+ }
10283
+ ) : /* @__PURE__ */ jsx("span", { className: "qa-text-10 qa-text-mid", children: t("redraw_no_engine") })
10284
+ ] })
10035
10285
  ) : captureError ? (
10036
10286
  // The render broke rather than being skipped — offer a retry
10037
10287
  // against the same selection instead of a dead-end message.
@@ -10064,8 +10314,8 @@ function CaptureMode() {
10064
10314
  {
10065
10315
  type: "button",
10066
10316
  onClick: () => void (async () => {
10067
- const granted = await enableExactShots();
10068
- if (granted && selection) void runCapture(selection.rect);
10317
+ const shot2 = await photographNow();
10318
+ if (shot2 && selection) void runCapture(selection.rect);
10069
10319
  })(),
10070
10320
  title: t("exact_hint"),
10071
10321
  className: "qa-tap qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-subtle qa-px-1.5 qa-py-0.5 qa-text-10 qa-text-hi qa-focus-ring",
@@ -10138,7 +10388,7 @@ function CaptureMode() {
10138
10388
  value: description,
10139
10389
  onChange: (e) => setDescription(e.target.value),
10140
10390
  onKeyDown: (e) => {
10141
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
10391
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10142
10392
  if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10143
10393
  e.preventDefault();
10144
10394
  setSeverity(SEVERITIES2[Number(e.key) - 1]);
@@ -10163,17 +10413,25 @@ function CaptureMode() {
10163
10413
  ]
10164
10414
  }
10165
10415
  ),
10416
+ /* @__PURE__ */ jsxs(
10417
+ "button",
10418
+ {
10419
+ "data-qa-save-next": "true",
10420
+ onClick: () => void save(true),
10421
+ disabled: !description.trim(),
10422
+ title: t("save_next_hint"),
10423
+ className: "qa-tap qa-flex qa-items-center qa-justify-center qa-gap-1.5 qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-hi",
10424
+ style: { background: "transparent", cursor: "pointer" },
10425
+ children: [
10426
+ /* @__PURE__ */ jsx(Icon, { name: "Plus", size: 16 }),
10427
+ t("save_next")
10428
+ ]
10429
+ }
10430
+ ),
10166
10431
  /* @__PURE__ */ jsx(
10167
10432
  "button",
10168
10433
  {
10169
- onClick: () => {
10170
- setPhase("selecting");
10171
- setSelection(null);
10172
- setShot(null);
10173
- setDescription("");
10174
- setSeverity("bug");
10175
- setTargetForensics(void 0);
10176
- },
10434
+ onClick: resetForNextNote,
10177
10435
  className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-mid",
10178
10436
  style: { background: "transparent", cursor: "pointer" },
10179
10437
  children: t("reselect")
@@ -10389,5 +10647,5 @@ function Qapture({ config }) {
10389
10647
  }
10390
10648
 
10391
10649
  export { Qapture, deleteQaDatabase, initQaStudio };
10392
- //# sourceMappingURL=chunk-G6O4SIEH.js.map
10393
- //# sourceMappingURL=chunk-G6O4SIEH.js.map
10650
+ //# sourceMappingURL=chunk-JGSKPW7Y.js.map
10651
+ //# sourceMappingURL=chunk-JGSKPW7Y.js.map