qapture2 0.8.0 → 0.8.3

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.
@@ -1725,11 +1725,15 @@ var STR = {
1725
1725
  sel_region: "Region selected",
1726
1726
  sel_element: "Element selected",
1727
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.",
1728
1729
  no_shot: "no screenshot (location saved)",
1729
1730
  annotate_placeholder: "What do you want to do here? (add / remove / change\u2026)",
1730
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",
1731
1735
  reselect: "Reselect",
1732
- save_hint: "\u2318/Ctrl + Enter to save",
1736
+ save_hint: "\u2318/Ctrl + Enter to save \xB7 add Shift to keep the same screenshot",
1733
1737
  login_with: "Log in with {field} + password.",
1734
1738
  used_count: "{n}/{m} used",
1735
1739
  used: "used",
@@ -1765,6 +1769,9 @@ var STR = {
1765
1769
  sev_design: "Design",
1766
1770
  sev_enhance: "Enhance",
1767
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.",
1768
1775
  status_verified: "Verified",
1769
1776
  context_attached: "{n} runtime events attached",
1770
1777
  start_walkthrough: "Start walkthrough",
@@ -1933,11 +1940,15 @@ var STR = {
1933
1940
  sel_region: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0645\u0646\u0637\u0642\u0629",
1934
1941
  sel_element: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0639\u0646\u0635\u0631",
1935
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.",
1936
1944
  no_shot: "\u0628\u062F\u0648\u0646 \u0635\u0648\u0631\u0629 (\u062A\u0645 \u062D\u0641\u0638 \u0627\u0644\u0645\u0648\u0642\u0639)",
1937
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)",
1938
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",
1939
1950
  reselect: "\u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u062A\u062D\u062F\u064A\u062F",
1940
- 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",
1941
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.",
1942
1953
  used_count: "{n}/{m} \u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
1943
1954
  used: "\u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
@@ -1973,6 +1984,9 @@ var STR = {
1973
1984
  sev_design: "Design",
1974
1985
  sev_enhance: "Enhance",
1975
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.",
1976
1990
  status_verified: "\u062A\u0645 \u0627\u0644\u062A\u062D\u0642\u0642",
1977
1991
  context_attached: "{n} \u0645\u0646 \u0623\u062D\u062F\u0627\u062B \u0627\u0644\u062A\u0634\u063A\u064A\u0644 \u0645\u0631\u0641\u0642\u0629",
1978
1992
  start_walkthrough: "\u0627\u0628\u062F\u0623 \u0627\u0644\u062C\u0648\u0644\u0629",
@@ -2369,6 +2383,25 @@ function getExactCaptureStatus() {
2369
2383
  function getFrozenFrame() {
2370
2384
  return frozen;
2371
2385
  }
2386
+ var STILL_REUSE_MS = 9e4;
2387
+ var frozenAtScrollX = 0;
2388
+ var frozenAtScrollY = 0;
2389
+ var frozenAtPath = "";
2390
+ function stillIsCurrent() {
2391
+ if (!frozen) return false;
2392
+ if (typeof window === "undefined") return false;
2393
+ if (Date.now() - frozen.takenAt > STILL_REUSE_MS) return false;
2394
+ if (window.scrollX !== frozenAtScrollX || window.scrollY !== frozenAtScrollY) return false;
2395
+ if (window.location.pathname + window.location.search !== frozenAtPath) return false;
2396
+ const vw = document.documentElement.clientWidth || window.innerWidth;
2397
+ const vh = document.documentElement.clientHeight || window.innerHeight;
2398
+ if (vw !== frozen.viewportWidth || vh !== frozen.viewportHeight) return false;
2399
+ return true;
2400
+ }
2401
+ async function freezeOrReuse() {
2402
+ if (stillIsCurrent()) return frozen;
2403
+ return freezeViewport();
2404
+ }
2372
2405
  function releaseFrozenFrame() {
2373
2406
  if (frozen) {
2374
2407
  frozen.canvas.width = 0;
@@ -2613,6 +2646,9 @@ async function freezeViewport() {
2613
2646
  viewportHeight: vh,
2614
2647
  takenAt: Date.now()
2615
2648
  };
2649
+ frozenAtScrollX = window.scrollX;
2650
+ frozenAtScrollY = window.scrollY;
2651
+ frozenAtPath = window.location.pathname + window.location.search;
2616
2652
  lastMode = mode;
2617
2653
  return frozen;
2618
2654
  } catch {
@@ -2786,12 +2822,21 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2786
2822
 
2787
2823
  // src/lib/capture.ts
2788
2824
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2825
+ var CHUNK_TIMEOUT_MS = 8e3;
2789
2826
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2790
2827
  var MAX_SHOT_EDGE = 1800;
2791
2828
  var WEBP_QUALITY = 0.92;
2792
2829
  var STUCK_ATTR = "data-qa-stuck";
2793
2830
  var MAX_CAPTURE_EDGE = 4e3;
2794
2831
  var MAX_RENDER_DEVICE_PIXELS = 16e6;
2832
+ var TOO_HEAVY_NODES = 6e3;
2833
+ function documentWeight() {
2834
+ try {
2835
+ return document.getElementsByTagName("*").length;
2836
+ } catch {
2837
+ return 0;
2838
+ }
2839
+ }
2795
2840
  function withTimeout(promise, ms) {
2796
2841
  let timer;
2797
2842
  return Promise.race([
@@ -2853,8 +2898,13 @@ function fitToBudget(canvas) {
2853
2898
  function canvasToBlob(canvas, type, quality) {
2854
2899
  return new Promise((resolve) => {
2855
2900
  if (canvas.toBlob) {
2856
- canvas.toBlob((b) => resolve(b), type, quality);
2857
- return;
2901
+ try {
2902
+ canvas.toBlob((b) => resolve(b), type, quality);
2903
+ return;
2904
+ } catch {
2905
+ resolve(null);
2906
+ return;
2907
+ }
2858
2908
  }
2859
2909
  try {
2860
2910
  const dataUrl = canvas.toDataURL(type, quality);
@@ -3041,7 +3091,9 @@ function planRender(rect, sx, sy, vw, vh) {
3041
3091
  };
3042
3092
  }
3043
3093
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3044
- const { default: html2canvas } = await import('html2canvas');
3094
+ const mod = await withTimeout(import('html2canvas'), CHUNK_TIMEOUT_MS);
3095
+ if (!mod) return null;
3096
+ const { default: html2canvas } = mod;
3045
3097
  const { vw, vh } = viewportSize();
3046
3098
  const plan = planRender(rect, sx, sy, vw, vh);
3047
3099
  let scale = Math.min(window.devicePixelRatio || 1, 2);
@@ -3060,7 +3112,22 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3060
3112
  height: plan.render.height,
3061
3113
  scale,
3062
3114
  useCORS: true,
3063
- allowTaint: true,
3115
+ // MUST stay false. `allowTaint: true` tells html2canvas to draw
3116
+ // cross-origin images it could not fetch with CORS -- which succeeds,
3117
+ // and TAINTS the canvas. A tainted canvas throws SecurityError from
3118
+ // toBlob/toDataURL, so the render finishes and then the encode dies,
3119
+ // and the tester gets "couldn't capture" with nothing to look at.
3120
+ //
3121
+ // That is one image away on any real page: a map tile, a news
3122
+ // thumbnail, an avatar, an ad, a CDN logo. It made image-heavy pages
3123
+ // fail 100% of the time while a plain page worked, which is exactly
3124
+ // the "it works for you and not for me" this kept producing.
3125
+ //
3126
+ // False is strictly better: html2canvas SKIPS the images it cannot
3127
+ // read, so those come out blank and everything else comes out right.
3128
+ // A screenshot with two grey rectangles in it is worth having. A
3129
+ // SecurityError is not.
3130
+ allowTaint: false,
3064
3131
  // The page's own background, never null — see resolvePageBackground().
3065
3132
  // Passed straight to html2canvas's parser, so it gets the same
3066
3133
  // CSS Color 4 treatment the document walk applies.
@@ -3109,6 +3176,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3109
3176
  }
3110
3177
  if (prefer === "exact") return { status: "failed" };
3111
3178
  }
3179
+ const weight = documentWeight();
3180
+ if (weight > TOO_HEAVY_NODES) {
3181
+ return { status: "too-heavy", nodes: weight };
3182
+ }
3112
3183
  try {
3113
3184
  const canvas = await captureViaDom(rect, sx, sy);
3114
3185
  if (!canvas) return { status: "failed" };
@@ -3166,6 +3237,12 @@ function formatEvent(ev, t0) {
3166
3237
  }
3167
3238
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3168
3239
  }
3240
+ function noteCheckLine(note, index) {
3241
+ const where = oneLine(note.route) || "/";
3242
+ const what = oneLine(note.description) || "(no description)";
3243
+ const trimmed = what.length > 160 ? `${what.slice(0, 157)}...` : what;
3244
+ return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3245
+ }
3169
3246
  function noteToMarkdown(note, opts) {
3170
3247
  const brand = opts?.brand ?? "Qapture";
3171
3248
  const idx = opts?.index;
@@ -3223,6 +3300,14 @@ function noteToMarkdown(note, opts) {
3223
3300
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3224
3301
  });
3225
3302
  }
3303
+ if (idx != null) {
3304
+ lines.push("");
3305
+ lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3306
+ lines.push("");
3307
+ 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?*");
3308
+ lines.push("");
3309
+ 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\`.`);
3310
+ }
3226
3311
  const ctx = note.context;
3227
3312
  if (ctx) {
3228
3313
  const env = ctx.env;
@@ -3395,6 +3480,17 @@ Second, **the previous attempt is evidence.** Something about the first reading
3395
3480
  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.
3396
3481
 
3397
3482
  And if a point is phrased as a question rather than a report, **answer it \u2014 do not change code on a maybe.**`
3483
+ );
3484
+ sections.push(
3485
+ `## You Are Being Graded On This
3486
+
3487
+ 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.
3488
+
3489
+ **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.
3490
+
3491
+ **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.
3492
+
3493
+ Hand \`verify.md\` back with the work.`
3398
3494
  );
3399
3495
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3400
3496
  const runArr = toStrings(p.runCommands);
@@ -3542,6 +3638,39 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3542
3638
  ` : "";
3543
3639
  const notesMd = preambleMd + "\n\n---NOTES---\n\n" + notesHeader + notesBody;
3544
3640
  zip.file("notes.md", notesMd);
3641
+ zip.file("verify.md", [
3642
+ `# ${brandLabel} \u2014 checks to satisfy`,
3643
+ "",
3644
+ `Exported: ${stamp}`,
3645
+ `Checks: ${notes.length}`,
3646
+ "",
3647
+ "Each line below is one thing the tester asked for. The full ask, the",
3648
+ "screenshot, the page and the element are in `notes.md` under the matching",
3649
+ '"Point" heading.',
3650
+ "",
3651
+ "**What is expected of you**",
3652
+ "",
3653
+ "1. Do the work in `notes.md`.",
3654
+ "2. Tick a box here only when the check is true on a fresh load of the page",
3655
+ " named beside it, with the tester doing nothing extra.",
3656
+ "3. Leave a box unticked and write one line under it saying why, if you",
3657
+ " could not do it or disagree with it. An unticked box with a reason is a",
3658
+ " good answer; a ticked box that is not true is the only bad one.",
3659
+ "4. Hand this file back with the work.",
3660
+ "",
3661
+ "The tester will then be walked back through these same checks on the real",
3662
+ "page, in this order, and asked about each one. Anything they say is still",
3663
+ "wrong comes back to you as round two, with their new words attached.",
3664
+ "",
3665
+ "---",
3666
+ "",
3667
+ ...notes.map((n, i) => noteCheckLine(n, i + 1)),
3668
+ "",
3669
+ "---",
3670
+ "",
3671
+ "Re-open the walkthrough on the tester's machine with `?qa=walk:verify`.",
3672
+ ""
3673
+ ].join("\n"));
3545
3674
  notes.forEach((n, i) => {
3546
3675
  if (n.screenshot && shots) {
3547
3676
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4181,6 +4310,9 @@ var GUIDE_FAILED_KEY = "guideFailed";
4181
4310
  var LOGIN_KEY = "logins";
4182
4311
  var PENDING_DELETE_KEY = "pendingDeleteIds";
4183
4312
  var EXACT_SHOTS_KEY = "exactShots";
4313
+ function exactShotsWanted(store) {
4314
+ return store.getItem(EXACT_SHOTS_KEY) !== "0";
4315
+ }
4184
4316
  var SIMPLE_MODE_KEY = "simpleMode";
4185
4317
  var COMPACT_KEY = "compactCapture";
4186
4318
  var LAST_CAMPAIGN_KEY = "lastCampaign";
@@ -4221,6 +4353,7 @@ function QaProvider({
4221
4353
  useEffect(() => {
4222
4354
  notesRef.current = notes;
4223
4355
  }, [notes]);
4356
+ const staleStillTimer = useRef(null);
4224
4357
  const applyNotes = useCallback((updater) => {
4225
4358
  const next = updater(notesRef.current);
4226
4359
  notesRef.current = next;
@@ -4259,7 +4392,7 @@ function QaProvider({
4259
4392
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4260
4393
  );
4261
4394
  const [exactStatus, setExactStatus] = useState(() => {
4262
- if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4395
+ if (exactShotsWanted(storage)) armExactCapture();
4263
4396
  return getExactCaptureStatus();
4264
4397
  });
4265
4398
  const exactSupported = isExactCaptureSupported();
@@ -4764,17 +4897,22 @@ function QaProvider({
4764
4897
  flushPendingDeletes();
4765
4898
  for (const timer of noticeTimers.current.values()) clearTimeout(timer);
4766
4899
  noticeTimers.current.clear();
4900
+ if (staleStillTimer.current) {
4901
+ clearTimeout(staleStillTimer.current);
4902
+ staleStillTimer.current = null;
4903
+ }
4767
4904
  releaseFrozenFrame();
4768
4905
  };
4769
4906
  }, [flushPendingDeletes]);
4907
+ const STILL_HOLD_MS = 1e5;
4770
4908
  const startCapture = useCallback((prefill) => {
4771
4909
  setIsOpen(false);
4772
4910
  setCapturePrefill(prefill ?? "");
4773
4911
  setCaptureActive(true);
4774
- setFrozenAt(null);
4775
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4912
+ if (!stillIsCurrent()) setFrozenAt(null);
4913
+ if (exactShotsWanted(storage) && isExactCaptureSupported()) {
4776
4914
  resetExactCaptureDecline();
4777
- void freezeViewport().then((frame) => {
4915
+ void freezeOrReuse().then((frame) => {
4778
4916
  setFrozenAt(frame?.takenAt ?? null);
4779
4917
  setExactStatus(getExactCaptureStatus());
4780
4918
  });
@@ -4782,8 +4920,11 @@ function QaProvider({
4782
4920
  }, [storage]);
4783
4921
  const endCapture = useCallback((reopen = true) => {
4784
4922
  setCaptureActive(false);
4785
- releaseFrozenFrame();
4786
- setFrozenAt(null);
4923
+ if (staleStillTimer.current) clearTimeout(staleStillTimer.current);
4924
+ staleStillTimer.current = setTimeout(() => {
4925
+ releaseFrozenFrame();
4926
+ staleStillTimer.current = null;
4927
+ }, STILL_HOLD_MS);
4787
4928
  if (reopen) setIsOpen(true);
4788
4929
  }, []);
4789
4930
  const toggleGuide = useCallback((key) => {
@@ -4804,19 +4945,36 @@ function QaProvider({
4804
4945
  return next;
4805
4946
  });
4806
4947
  }, [storage]);
4948
+ const stampHandedOff = useCallback(async (ids, at) => {
4949
+ if (!ids.length) return;
4950
+ const idSet = new Set(ids);
4951
+ const touched = [];
4952
+ applyNotes(
4953
+ (prev) => prev.map((n) => {
4954
+ if (!idSet.has(n.id)) return n;
4955
+ if ((n.status ?? "open") === "verified") return n;
4956
+ const next = { ...n, handedOffAt: at };
4957
+ touched.push(next);
4958
+ return next;
4959
+ })
4960
+ );
4961
+ for (const note of touched) await idb.put(note);
4962
+ }, [applyNotes, idb]);
4807
4963
  const exportZipFn = useCallback(
4808
4964
  async (filename) => {
4809
4965
  if (!notes.length || isExporting) return;
4810
4966
  setIsExporting(true);
4811
4967
  try {
4812
- await buildAndDownloadZip(notes, nowIso(), filename, config, guideChecked, guideSkipped);
4968
+ const stamp = nowIso();
4969
+ await buildAndDownloadZip(notes, stamp, filename, config, guideChecked, guideSkipped);
4970
+ await stampHandedOff(notes.map((n) => n.id), stamp);
4813
4971
  } catch (err) {
4814
4972
  console.error("[QA] export failed", err);
4815
4973
  } finally {
4816
4974
  setIsExporting(false);
4817
4975
  }
4818
4976
  },
4819
- [notes, isExporting, config, guideChecked, guideSkipped]
4977
+ [notes, isExporting, config, guideChecked, guideSkipped, stampHandedOff]
4820
4978
  );
4821
4979
  const exportZipRef = useRef(async () => {
4822
4980
  });
@@ -5002,6 +5160,7 @@ function QaProvider({
5002
5160
  design: 0,
5003
5161
  enhance: 0,
5004
5162
  open: 0,
5163
+ sent: 0,
5005
5164
  fixed: 0,
5006
5165
  verified: 0,
5007
5166
  thisPage: 0
@@ -5015,6 +5174,7 @@ function QaProvider({
5015
5174
  if (status === "verified") counts.verified++;
5016
5175
  else if (status === "fixed") counts.fixed++;
5017
5176
  else counts.open++;
5177
+ if (n.handedOffAt && status !== "verified") counts.sent++;
5018
5178
  if (n.route.split("?")[0] === currentRoute) counts.thisPage++;
5019
5179
  }
5020
5180
  return counts;
@@ -5023,7 +5183,9 @@ function QaProvider({
5023
5183
  const q = filter.query.trim().toLowerCase();
5024
5184
  return notes.filter((n) => {
5025
5185
  if (filter.severity !== "all" && (n.severity ?? "bug") !== filter.severity) return false;
5026
- if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5186
+ if (filter.status === "sent") {
5187
+ if (!n.handedOffAt || (n.status ?? "open") === "verified") return false;
5188
+ } else if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5027
5189
  if (filter.thisPageOnly && n.route.split("?")[0] !== currentRoute) return false;
5028
5190
  if (!q) return true;
5029
5191
  const haystack = `${n.description} ${n.route} ${n.target?.selector ?? ""} ${n.target?.text ?? ""}`;
@@ -5068,6 +5230,7 @@ function QaProvider({
5068
5230
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
5069
5231
  if (!blob) return { status: "unsupported" };
5070
5232
  const outcome = await shareZipFile(blob, name, config.brand.label);
5233
+ await stampHandedOff(notes.map((n) => n.id), stamp);
5071
5234
  if (outcome.status === "needs-gesture") {
5072
5235
  setPendingShare({ blob, filename: name });
5073
5236
  } else if (outcome.status === "unsupported") {
@@ -5079,7 +5242,7 @@ function QaProvider({
5079
5242
  } finally {
5080
5243
  setIsExporting(false);
5081
5244
  }
5082
- }, [notes, config, guideChecked, guideSkipped]);
5245
+ }, [notes, config, guideChecked, guideSkipped, stampHandedOff]);
5083
5246
  const sharePending = useCallback(async () => {
5084
5247
  const pending = pendingShare;
5085
5248
  if (!pending) return { status: "unsupported" };
@@ -5295,7 +5458,10 @@ function QaProvider({
5295
5458
  window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
5296
5459
  } catch {
5297
5460
  }
5298
- if (which === "retest") {
5461
+ if (which === "verify") {
5462
+ setFilterState((prev) => ({ ...prev, status: "sent", severity: "all", thisPageOnly: false }));
5463
+ startWalk("notes", 0);
5464
+ } else if (which === "retest") {
5299
5465
  setFilterState((prev) => ({ ...prev, status: "fixed", severity: "all", thisPageOnly: false }));
5300
5466
  startWalk("notes", 0);
5301
5467
  } else if (which === "notes") {
@@ -7353,7 +7519,7 @@ function FilterChip({ chip }) {
7353
7519
  );
7354
7520
  }
7355
7521
  function NoteFilterBar() {
7356
- const { filter, setFilter, noteCounts, notes, deleteNotes, t } = useQa();
7522
+ const { filter, setFilter, noteCounts, notes, deleteNotes, startWalk, t } = useQa();
7357
7523
  const [confirmPrune, setConfirmPrune] = useState(false);
7358
7524
  if (notes.length < 3) return null;
7359
7525
  const others = notes.filter((n) => (n.status ?? "open") !== "fixed");
@@ -7395,6 +7561,18 @@ function NoteFilterBar() {
7395
7561
  tone: "accent",
7396
7562
  onClick: sev("design")
7397
7563
  },
7564
+ // Only ever shown once something has actually been sent. A chip reading
7565
+ // "Sent 0" on a first run is noise; the same chip reading "Sent 6" the
7566
+ // morning after an export is the one thing the tester came back for.
7567
+ ...noteCounts.sent > 0 ? [{
7568
+ key: "sent",
7569
+ label: t("status_sent"),
7570
+ count: noteCounts.sent,
7571
+ active: filter.status === "sent",
7572
+ icon: "Camera",
7573
+ tone: "accent",
7574
+ onClick: () => setFilter({ status: "sent", severity: "all", thisPageOnly: false })
7575
+ }] : [],
7398
7576
  {
7399
7577
  key: "open",
7400
7578
  label: t("status_open"),
@@ -7463,6 +7641,24 @@ function NoteFilterBar() {
7463
7641
  }
7464
7642
  ),
7465
7643
  /* @__PURE__ */ jsx("div", { className: "qa-flex qa-flex-wrap qa-gap-1", children: chips.map((chip) => /* @__PURE__ */ jsx(FilterChip, { chip }, chip.key)) }),
7644
+ noteCounts.sent > 0 && /* @__PURE__ */ jsxs(
7645
+ "button",
7646
+ {
7647
+ type: "button",
7648
+ "data-qa-verify-walk": "true",
7649
+ onClick: () => {
7650
+ setFilter({ status: "sent", severity: "all", thisPageOnly: false });
7651
+ startWalk("notes", 0);
7652
+ },
7653
+ title: t("check_fixes_hint"),
7654
+ 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",
7655
+ style: { background: "transparent", cursor: "pointer" },
7656
+ children: [
7657
+ /* @__PURE__ */ jsx(Icon, { name: "Check", size: 13 }),
7658
+ t("check_fixes", { n: noteCounts.sent })
7659
+ ]
7660
+ }
7661
+ ),
7466
7662
  showPrune && /* @__PURE__ */ jsx("div", { className: "qa-text-11", "data-qa-prune": "true", children: confirmPrune ? /* @__PURE__ */ jsxs("span", { className: "qa-text-mid", children: [
7467
7663
  t("keep_only_retest_q", { n: others.length }),
7468
7664
  " ",
@@ -9218,10 +9414,12 @@ function CaptureMode() {
9218
9414
  const [regionMode, setRegionMode] = useState(false);
9219
9415
  const [shot, setShot] = useState(null);
9220
9416
  const stillRef = useRef(null);
9417
+ const [notesFromThisShot, setNotesFromThisShot] = useState(0);
9221
9418
  const [shotEngine, setShotEngine] = useState(null);
9222
9419
  const [shotUrl, setShotUrl] = useState(null);
9223
9420
  const [capturing, setCapturing] = useState(false);
9224
9421
  const [captureError, setCaptureError] = useState(false);
9422
+ const [tooHeavy, setTooHeavy] = useState(false);
9225
9423
  const [description, setDescription] = useState(capturePrefill);
9226
9424
  const [severity, setSeverity] = useState("bug");
9227
9425
  const [targetForensics, setTargetForensics] = useState(void 0);
@@ -9251,6 +9449,7 @@ function CaptureMode() {
9251
9449
  const runCapture = useCallback(async (rect) => {
9252
9450
  setCapturing(true);
9253
9451
  setCaptureError(false);
9452
+ setTooHeavy(false);
9254
9453
  lockPageScroll();
9255
9454
  try {
9256
9455
  const outcome = await captureRegion(rect, scrollSnap.current);
@@ -9261,6 +9460,7 @@ function CaptureMode() {
9261
9460
  return;
9262
9461
  }
9263
9462
  setCaptureError(outcome.status === "failed");
9463
+ setTooHeavy(outcome.status === "too-heavy");
9264
9464
  setShotEngine(outcome.status === "ok" ? outcome.engine : null);
9265
9465
  setShot(blob);
9266
9466
  setShotUrl((old) => {
@@ -9522,7 +9722,17 @@ function CaptureMode() {
9522
9722
  canvas.removeAttribute("style");
9523
9723
  };
9524
9724
  }, [exactShots.frozenAt]);
9525
- const save = async () => {
9725
+ const resetForNextNote = useCallback(() => {
9726
+ setPhase("selecting");
9727
+ setSelection(null);
9728
+ setShot(null);
9729
+ setShotEngine(null);
9730
+ setDescription("");
9731
+ setSeverity("bug");
9732
+ setTargetForensics(void 0);
9733
+ setCaptureError(false);
9734
+ }, []);
9735
+ const save = async (keepGoing = false) => {
9526
9736
  if (!selection || !description.trim()) return;
9527
9737
  const target = {
9528
9738
  kind: selection.kind,
@@ -9544,6 +9754,11 @@ function CaptureMode() {
9544
9754
  severity,
9545
9755
  forensics: selection.kind === "element" ? targetForensics : void 0
9546
9756
  });
9757
+ if (keepGoing) {
9758
+ setNotesFromThisShot((n) => n + 1);
9759
+ resetForNextNote();
9760
+ return;
9761
+ }
9547
9762
  endCapture();
9548
9763
  };
9549
9764
  const popStyleFor = useCallback((r, cardWidth = 340) => {
@@ -9611,6 +9826,21 @@ function CaptureMode() {
9611
9826
  {
9612
9827
  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",
9613
9828
  children: [
9829
+ notesFromThisShot > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
9830
+ /* @__PURE__ */ jsxs(
9831
+ "span",
9832
+ {
9833
+ "data-qa-shot-reused": "true",
9834
+ className: "qa-flex qa-items-center qa-gap-1.5",
9835
+ style: { color: "var(--qa-success)" },
9836
+ children: [
9837
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 16 }),
9838
+ t("shot_reused", { n: String(notesFromThisShot) })
9839
+ ]
9840
+ }
9841
+ ),
9842
+ /* @__PURE__ */ jsx("span", { className: "qa-opacity-50", children: "\xB7" })
9843
+ ] }),
9614
9844
  /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-1.5", children: [
9615
9845
  /* @__PURE__ */ jsx(Icon, { name: "MousePointerClick", size: 16 }),
9616
9846
  t("cap_click")
@@ -10060,6 +10290,29 @@ function CaptureMode() {
10060
10290
  )
10061
10291
  ]
10062
10292
  }
10293
+ ) : tooHeavy ? (
10294
+ // Not a failure to report — a refusal to explain, with the one
10295
+ // control that actually solves it.
10296
+ /* @__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: [
10297
+ /* @__PURE__ */ jsx("span", { className: "qa-text-xs qa-text-mid", children: t("too_heavy") }),
10298
+ exactShots.supported ? /* @__PURE__ */ jsxs(
10299
+ "button",
10300
+ {
10301
+ type: "button",
10302
+ onClick: () => void (async () => {
10303
+ const shot2 = await photographNow();
10304
+ if (shot2 && selection) void runCapture(selection.rect);
10305
+ })(),
10306
+ title: t("exact_hint"),
10307
+ 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",
10308
+ style: { background: "transparent", cursor: "pointer" },
10309
+ children: [
10310
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 13 }),
10311
+ t("redraw_action")
10312
+ ]
10313
+ }
10314
+ ) : /* @__PURE__ */ jsx("span", { className: "qa-text-10 qa-text-mid", children: t("redraw_no_engine") })
10315
+ ] })
10063
10316
  ) : captureError ? (
10064
10317
  // The render broke rather than being skipped — offer a retry
10065
10318
  // against the same selection instead of a dead-end message.
@@ -10166,7 +10419,7 @@ function CaptureMode() {
10166
10419
  value: description,
10167
10420
  onChange: (e) => setDescription(e.target.value),
10168
10421
  onKeyDown: (e) => {
10169
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
10422
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10170
10423
  if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10171
10424
  e.preventDefault();
10172
10425
  setSeverity(SEVERITIES2[Number(e.key) - 1]);
@@ -10191,17 +10444,25 @@ function CaptureMode() {
10191
10444
  ]
10192
10445
  }
10193
10446
  ),
10447
+ /* @__PURE__ */ jsxs(
10448
+ "button",
10449
+ {
10450
+ "data-qa-save-next": "true",
10451
+ onClick: () => void save(true),
10452
+ disabled: !description.trim(),
10453
+ title: t("save_next_hint"),
10454
+ 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",
10455
+ style: { background: "transparent", cursor: "pointer" },
10456
+ children: [
10457
+ /* @__PURE__ */ jsx(Icon, { name: "Plus", size: 16 }),
10458
+ t("save_next")
10459
+ ]
10460
+ }
10461
+ ),
10194
10462
  /* @__PURE__ */ jsx(
10195
10463
  "button",
10196
10464
  {
10197
- onClick: () => {
10198
- setPhase("selecting");
10199
- setSelection(null);
10200
- setShot(null);
10201
- setDescription("");
10202
- setSeverity("bug");
10203
- setTargetForensics(void 0);
10204
- },
10465
+ onClick: resetForNextNote,
10205
10466
  className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-mid",
10206
10467
  style: { background: "transparent", cursor: "pointer" },
10207
10468
  children: t("reselect")
@@ -10417,5 +10678,5 @@ function Qapture({ config }) {
10417
10678
  }
10418
10679
 
10419
10680
  export { Qapture, deleteQaDatabase, initQaStudio };
10420
- //# sourceMappingURL=chunk-RJV6FTIB.js.map
10421
- //# sourceMappingURL=chunk-RJV6FTIB.js.map
10681
+ //# sourceMappingURL=chunk-32LKZ2PG.js.map
10682
+ //# sourceMappingURL=chunk-32LKZ2PG.js.map