qapture2 0.8.0 → 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.
@@ -1732,11 +1732,15 @@ var STR = {
1732
1732
  sel_region: "Region selected",
1733
1733
  sel_element: "Element selected",
1734
1734
  capturing: "capturing screenshot\u2026",
1735
+ 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.",
1735
1736
  no_shot: "no screenshot (location saved)",
1736
1737
  annotate_placeholder: "What do you want to do here? (add / remove / change\u2026)",
1737
1738
  save_point: "Save point",
1739
+ save_next: "Save + next",
1740
+ save_next_hint: "Save this one and mark up another part of the same screenshot \u2014 no second permission prompt.",
1741
+ shot_reused: "{n} saved from this screenshot",
1738
1742
  reselect: "Reselect",
1739
- save_hint: "\u2318/Ctrl + Enter to save",
1743
+ save_hint: "\u2318/Ctrl + Enter to save \xB7 add Shift to keep the same screenshot",
1740
1744
  login_with: "Log in with {field} + password.",
1741
1745
  used_count: "{n}/{m} used",
1742
1746
  used: "used",
@@ -1772,6 +1776,9 @@ var STR = {
1772
1776
  sev_design: "Design",
1773
1777
  sev_enhance: "Enhance",
1774
1778
  status_open: "Open",
1779
+ status_sent: "Sent",
1780
+ check_fixes: "Check the {n} fixes",
1781
+ 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.",
1775
1782
  status_verified: "Verified",
1776
1783
  context_attached: "{n} runtime events attached",
1777
1784
  start_walkthrough: "Start walkthrough",
@@ -1940,11 +1947,15 @@ var STR = {
1940
1947
  sel_region: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0645\u0646\u0637\u0642\u0629",
1941
1948
  sel_element: "\u062A\u0645 \u062A\u062D\u062F\u064A\u062F \u0639\u0646\u0635\u0631",
1942
1949
  capturing: "\u064A\u062A\u0645 \u0627\u0644\u062A\u0642\u0627\u0637 \u0627\u0644\u0635\u0648\u0631\u0629\u2026",
1950
+ 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.",
1943
1951
  no_shot: "\u0628\u062F\u0648\u0646 \u0635\u0648\u0631\u0629 (\u062A\u0645 \u062D\u0641\u0638 \u0627\u0644\u0645\u0648\u0642\u0639)",
1944
1952
  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)",
1945
1953
  save_point: "\u062D\u0641\u0638 \u0627\u0644\u0646\u0642\u0637\u0629",
1954
+ save_next: "\u062D\u0641\u0638 + \u0627\u0644\u062A\u0627\u0644\u064A",
1955
+ 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.",
1956
+ shot_reused: "\u062D\u064F\u0641\u0638\u062A {n} \u0645\u0646 \u0647\u0630\u0647 \u0627\u0644\u0644\u0642\u0637\u0629",
1946
1957
  reselect: "\u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u062A\u062D\u062F\u064A\u062F",
1947
- save_hint: "\u2318/Ctrl + Enter \u0644\u0644\u062D\u0641\u0638",
1958
+ 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",
1948
1959
  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.",
1949
1960
  used_count: "{n}/{m} \u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
1950
1961
  used: "\u0645\u064F\u0633\u062A\u062E\u062F\u064E\u0645",
@@ -1980,6 +1991,9 @@ var STR = {
1980
1991
  sev_design: "Design",
1981
1992
  sev_enhance: "Enhance",
1982
1993
  status_open: "\u0645\u0641\u062A\u0648\u062D",
1994
+ status_sent: "\u0645\u064F\u0631\u0633\u064E\u0644",
1995
+ check_fixes: "\u062A\u062D\u0642\u0651\u0642 \u0645\u0646 {n} \u062A\u0639\u062F\u064A\u0644\u0627\u062A",
1996
+ 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.",
1983
1997
  status_verified: "\u062A\u0645 \u0627\u0644\u062A\u062D\u0642\u0642",
1984
1998
  context_attached: "{n} \u0645\u0646 \u0623\u062D\u062F\u0627\u062B \u0627\u0644\u062A\u0634\u063A\u064A\u0644 \u0645\u0631\u0641\u0642\u0629",
1985
1999
  start_walkthrough: "\u0627\u0628\u062F\u0623 \u0627\u0644\u062C\u0648\u0644\u0629",
@@ -2793,12 +2807,21 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2793
2807
 
2794
2808
  // src/lib/capture.ts
2795
2809
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2810
+ var CHUNK_TIMEOUT_MS = 8e3;
2796
2811
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2797
2812
  var MAX_SHOT_EDGE = 1800;
2798
2813
  var WEBP_QUALITY = 0.92;
2799
2814
  var STUCK_ATTR = "data-qa-stuck";
2800
2815
  var MAX_CAPTURE_EDGE = 4e3;
2801
2816
  var MAX_RENDER_DEVICE_PIXELS = 16e6;
2817
+ var TOO_HEAVY_NODES = 6e3;
2818
+ function documentWeight() {
2819
+ try {
2820
+ return document.getElementsByTagName("*").length;
2821
+ } catch {
2822
+ return 0;
2823
+ }
2824
+ }
2802
2825
  function withTimeout(promise, ms) {
2803
2826
  let timer;
2804
2827
  return Promise.race([
@@ -2860,8 +2883,13 @@ function fitToBudget(canvas) {
2860
2883
  function canvasToBlob(canvas, type, quality) {
2861
2884
  return new Promise((resolve) => {
2862
2885
  if (canvas.toBlob) {
2863
- canvas.toBlob((b) => resolve(b), type, quality);
2864
- return;
2886
+ try {
2887
+ canvas.toBlob((b) => resolve(b), type, quality);
2888
+ return;
2889
+ } catch {
2890
+ resolve(null);
2891
+ return;
2892
+ }
2865
2893
  }
2866
2894
  try {
2867
2895
  const dataUrl = canvas.toDataURL(type, quality);
@@ -3048,7 +3076,9 @@ function planRender(rect, sx, sy, vw, vh) {
3048
3076
  };
3049
3077
  }
3050
3078
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3051
- const { default: html2canvas } = await import('html2canvas');
3079
+ const mod = await withTimeout(import('html2canvas'), CHUNK_TIMEOUT_MS);
3080
+ if (!mod) return null;
3081
+ const { default: html2canvas } = mod;
3052
3082
  const { vw, vh } = viewportSize();
3053
3083
  const plan = planRender(rect, sx, sy, vw, vh);
3054
3084
  let scale = Math.min(window.devicePixelRatio || 1, 2);
@@ -3067,7 +3097,22 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3067
3097
  height: plan.render.height,
3068
3098
  scale,
3069
3099
  useCORS: true,
3070
- allowTaint: true,
3100
+ // MUST stay false. `allowTaint: true` tells html2canvas to draw
3101
+ // cross-origin images it could not fetch with CORS -- which succeeds,
3102
+ // and TAINTS the canvas. A tainted canvas throws SecurityError from
3103
+ // toBlob/toDataURL, so the render finishes and then the encode dies,
3104
+ // and the tester gets "couldn't capture" with nothing to look at.
3105
+ //
3106
+ // That is one image away on any real page: a map tile, a news
3107
+ // thumbnail, an avatar, an ad, a CDN logo. It made image-heavy pages
3108
+ // fail 100% of the time while a plain page worked, which is exactly
3109
+ // the "it works for you and not for me" this kept producing.
3110
+ //
3111
+ // False is strictly better: html2canvas SKIPS the images it cannot
3112
+ // read, so those come out blank and everything else comes out right.
3113
+ // A screenshot with two grey rectangles in it is worth having. A
3114
+ // SecurityError is not.
3115
+ allowTaint: false,
3071
3116
  // The page's own background, never null — see resolvePageBackground().
3072
3117
  // Passed straight to html2canvas's parser, so it gets the same
3073
3118
  // CSS Color 4 treatment the document walk applies.
@@ -3116,6 +3161,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3116
3161
  }
3117
3162
  if (prefer === "exact") return { status: "failed" };
3118
3163
  }
3164
+ const weight = documentWeight();
3165
+ if (weight > TOO_HEAVY_NODES) {
3166
+ return { status: "too-heavy", nodes: weight };
3167
+ }
3119
3168
  try {
3120
3169
  const canvas = await captureViaDom(rect, sx, sy);
3121
3170
  if (!canvas) return { status: "failed" };
@@ -3173,6 +3222,12 @@ function formatEvent(ev, t0) {
3173
3222
  }
3174
3223
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3175
3224
  }
3225
+ function noteCheckLine(note, index) {
3226
+ const where = oneLine(note.route) || "/";
3227
+ const what = oneLine(note.description) || "(no description)";
3228
+ const trimmed = what.length > 160 ? `${what.slice(0, 157)}...` : what;
3229
+ return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3230
+ }
3176
3231
  function noteToMarkdown(note, opts) {
3177
3232
  const brand = opts?.brand ?? "Qapture";
3178
3233
  const idx = opts?.index;
@@ -3230,6 +3285,14 @@ function noteToMarkdown(note, opts) {
3230
3285
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3231
3286
  });
3232
3287
  }
3288
+ if (idx != null) {
3289
+ lines.push("");
3290
+ lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3291
+ lines.push("");
3292
+ 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?*");
3293
+ lines.push("");
3294
+ 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\`.`);
3295
+ }
3233
3296
  const ctx = note.context;
3234
3297
  if (ctx) {
3235
3298
  const env = ctx.env;
@@ -3402,6 +3465,17 @@ Second, **the previous attempt is evidence.** Something about the first reading
3402
3465
  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.
3403
3466
 
3404
3467
  And if a point is phrased as a question rather than a report, **answer it \u2014 do not change code on a maybe.**`
3468
+ );
3469
+ sections.push(
3470
+ `## You Are Being Graded On This
3471
+
3472
+ 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.
3473
+
3474
+ **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.
3475
+
3476
+ **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.
3477
+
3478
+ Hand \`verify.md\` back with the work.`
3405
3479
  );
3406
3480
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3407
3481
  const runArr = toStrings(p.runCommands);
@@ -3549,6 +3623,39 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3549
3623
  ` : "";
3550
3624
  const notesMd = preambleMd + "\n\n---NOTES---\n\n" + notesHeader + notesBody;
3551
3625
  zip.file("notes.md", notesMd);
3626
+ zip.file("verify.md", [
3627
+ `# ${brandLabel} \u2014 checks to satisfy`,
3628
+ "",
3629
+ `Exported: ${stamp}`,
3630
+ `Checks: ${notes.length}`,
3631
+ "",
3632
+ "Each line below is one thing the tester asked for. The full ask, the",
3633
+ "screenshot, the page and the element are in `notes.md` under the matching",
3634
+ '"Point" heading.',
3635
+ "",
3636
+ "**What is expected of you**",
3637
+ "",
3638
+ "1. Do the work in `notes.md`.",
3639
+ "2. Tick a box here only when the check is true on a fresh load of the page",
3640
+ " named beside it, with the tester doing nothing extra.",
3641
+ "3. Leave a box unticked and write one line under it saying why, if you",
3642
+ " could not do it or disagree with it. An unticked box with a reason is a",
3643
+ " good answer; a ticked box that is not true is the only bad one.",
3644
+ "4. Hand this file back with the work.",
3645
+ "",
3646
+ "The tester will then be walked back through these same checks on the real",
3647
+ "page, in this order, and asked about each one. Anything they say is still",
3648
+ "wrong comes back to you as round two, with their new words attached.",
3649
+ "",
3650
+ "---",
3651
+ "",
3652
+ ...notes.map((n, i) => noteCheckLine(n, i + 1)),
3653
+ "",
3654
+ "---",
3655
+ "",
3656
+ "Re-open the walkthrough on the tester's machine with `?qa=walk:verify`.",
3657
+ ""
3658
+ ].join("\n"));
3552
3659
  notes.forEach((n, i) => {
3553
3660
  if (n.screenshot && shots) {
3554
3661
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4188,6 +4295,9 @@ var GUIDE_FAILED_KEY = "guideFailed";
4188
4295
  var LOGIN_KEY = "logins";
4189
4296
  var PENDING_DELETE_KEY = "pendingDeleteIds";
4190
4297
  var EXACT_SHOTS_KEY = "exactShots";
4298
+ function exactShotsWanted(store) {
4299
+ return store.getItem(EXACT_SHOTS_KEY) !== "0";
4300
+ }
4191
4301
  var SIMPLE_MODE_KEY = "simpleMode";
4192
4302
  var COMPACT_KEY = "compactCapture";
4193
4303
  var LAST_CAMPAIGN_KEY = "lastCampaign";
@@ -4266,7 +4376,7 @@ function QaProvider({
4266
4376
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4267
4377
  );
4268
4378
  const [exactStatus, setExactStatus] = React.useState(() => {
4269
- if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4379
+ if (exactShotsWanted(storage)) armExactCapture();
4270
4380
  return getExactCaptureStatus();
4271
4381
  });
4272
4382
  const exactSupported = isExactCaptureSupported();
@@ -4779,7 +4889,7 @@ function QaProvider({
4779
4889
  setCapturePrefill(prefill ?? "");
4780
4890
  setCaptureActive(true);
4781
4891
  setFrozenAt(null);
4782
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4892
+ if (exactShotsWanted(storage) && isExactCaptureSupported()) {
4783
4893
  resetExactCaptureDecline();
4784
4894
  void freezeViewport().then((frame) => {
4785
4895
  setFrozenAt(frame?.takenAt ?? null);
@@ -4811,19 +4921,36 @@ function QaProvider({
4811
4921
  return next;
4812
4922
  });
4813
4923
  }, [storage]);
4924
+ const stampHandedOff = React.useCallback(async (ids, at) => {
4925
+ if (!ids.length) return;
4926
+ const idSet = new Set(ids);
4927
+ const touched = [];
4928
+ applyNotes(
4929
+ (prev) => prev.map((n) => {
4930
+ if (!idSet.has(n.id)) return n;
4931
+ if ((n.status ?? "open") === "verified") return n;
4932
+ const next = { ...n, handedOffAt: at };
4933
+ touched.push(next);
4934
+ return next;
4935
+ })
4936
+ );
4937
+ for (const note of touched) await idb.put(note);
4938
+ }, [applyNotes, idb]);
4814
4939
  const exportZipFn = React.useCallback(
4815
4940
  async (filename) => {
4816
4941
  if (!notes.length || isExporting) return;
4817
4942
  setIsExporting(true);
4818
4943
  try {
4819
- await buildAndDownloadZip(notes, nowIso(), filename, config, guideChecked, guideSkipped);
4944
+ const stamp = nowIso();
4945
+ await buildAndDownloadZip(notes, stamp, filename, config, guideChecked, guideSkipped);
4946
+ await stampHandedOff(notes.map((n) => n.id), stamp);
4820
4947
  } catch (err) {
4821
4948
  console.error("[QA] export failed", err);
4822
4949
  } finally {
4823
4950
  setIsExporting(false);
4824
4951
  }
4825
4952
  },
4826
- [notes, isExporting, config, guideChecked, guideSkipped]
4953
+ [notes, isExporting, config, guideChecked, guideSkipped, stampHandedOff]
4827
4954
  );
4828
4955
  const exportZipRef = React.useRef(async () => {
4829
4956
  });
@@ -5009,6 +5136,7 @@ function QaProvider({
5009
5136
  design: 0,
5010
5137
  enhance: 0,
5011
5138
  open: 0,
5139
+ sent: 0,
5012
5140
  fixed: 0,
5013
5141
  verified: 0,
5014
5142
  thisPage: 0
@@ -5022,6 +5150,7 @@ function QaProvider({
5022
5150
  if (status === "verified") counts.verified++;
5023
5151
  else if (status === "fixed") counts.fixed++;
5024
5152
  else counts.open++;
5153
+ if (n.handedOffAt && status !== "verified") counts.sent++;
5025
5154
  if (n.route.split("?")[0] === currentRoute) counts.thisPage++;
5026
5155
  }
5027
5156
  return counts;
@@ -5030,7 +5159,9 @@ function QaProvider({
5030
5159
  const q = filter.query.trim().toLowerCase();
5031
5160
  return notes.filter((n) => {
5032
5161
  if (filter.severity !== "all" && (n.severity ?? "bug") !== filter.severity) return false;
5033
- if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5162
+ if (filter.status === "sent") {
5163
+ if (!n.handedOffAt || (n.status ?? "open") === "verified") return false;
5164
+ } else if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5034
5165
  if (filter.thisPageOnly && n.route.split("?")[0] !== currentRoute) return false;
5035
5166
  if (!q) return true;
5036
5167
  const haystack = `${n.description} ${n.route} ${n.target?.selector ?? ""} ${n.target?.text ?? ""}`;
@@ -5075,6 +5206,7 @@ function QaProvider({
5075
5206
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
5076
5207
  if (!blob) return { status: "unsupported" };
5077
5208
  const outcome = await shareZipFile(blob, name, config.brand.label);
5209
+ await stampHandedOff(notes.map((n) => n.id), stamp);
5078
5210
  if (outcome.status === "needs-gesture") {
5079
5211
  setPendingShare({ blob, filename: name });
5080
5212
  } else if (outcome.status === "unsupported") {
@@ -5086,7 +5218,7 @@ function QaProvider({
5086
5218
  } finally {
5087
5219
  setIsExporting(false);
5088
5220
  }
5089
- }, [notes, config, guideChecked, guideSkipped]);
5221
+ }, [notes, config, guideChecked, guideSkipped, stampHandedOff]);
5090
5222
  const sharePending = React.useCallback(async () => {
5091
5223
  const pending = pendingShare;
5092
5224
  if (!pending) return { status: "unsupported" };
@@ -5302,7 +5434,10 @@ function QaProvider({
5302
5434
  window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
5303
5435
  } catch {
5304
5436
  }
5305
- if (which === "retest") {
5437
+ if (which === "verify") {
5438
+ setFilterState((prev) => ({ ...prev, status: "sent", severity: "all", thisPageOnly: false }));
5439
+ startWalk("notes", 0);
5440
+ } else if (which === "retest") {
5306
5441
  setFilterState((prev) => ({ ...prev, status: "fixed", severity: "all", thisPageOnly: false }));
5307
5442
  startWalk("notes", 0);
5308
5443
  } else if (which === "notes") {
@@ -7360,7 +7495,7 @@ function FilterChip({ chip }) {
7360
7495
  );
7361
7496
  }
7362
7497
  function NoteFilterBar() {
7363
- const { filter, setFilter, noteCounts, notes, deleteNotes, t } = useQa();
7498
+ const { filter, setFilter, noteCounts, notes, deleteNotes, startWalk, t } = useQa();
7364
7499
  const [confirmPrune, setConfirmPrune] = React.useState(false);
7365
7500
  if (notes.length < 3) return null;
7366
7501
  const others = notes.filter((n) => (n.status ?? "open") !== "fixed");
@@ -7402,6 +7537,18 @@ function NoteFilterBar() {
7402
7537
  tone: "accent",
7403
7538
  onClick: sev("design")
7404
7539
  },
7540
+ // Only ever shown once something has actually been sent. A chip reading
7541
+ // "Sent 0" on a first run is noise; the same chip reading "Sent 6" the
7542
+ // morning after an export is the one thing the tester came back for.
7543
+ ...noteCounts.sent > 0 ? [{
7544
+ key: "sent",
7545
+ label: t("status_sent"),
7546
+ count: noteCounts.sent,
7547
+ active: filter.status === "sent",
7548
+ icon: "Camera",
7549
+ tone: "accent",
7550
+ onClick: () => setFilter({ status: "sent", severity: "all", thisPageOnly: false })
7551
+ }] : [],
7405
7552
  {
7406
7553
  key: "open",
7407
7554
  label: t("status_open"),
@@ -7470,6 +7617,24 @@ function NoteFilterBar() {
7470
7617
  }
7471
7618
  ),
7472
7619
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-flex qa-flex-wrap qa-gap-1", children: chips.map((chip) => /* @__PURE__ */ jsxRuntime.jsx(FilterChip, { chip }, chip.key)) }),
7620
+ noteCounts.sent > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
7621
+ "button",
7622
+ {
7623
+ type: "button",
7624
+ "data-qa-verify-walk": "true",
7625
+ onClick: () => {
7626
+ setFilter({ status: "sent", severity: "all", thisPageOnly: false });
7627
+ startWalk("notes", 0);
7628
+ },
7629
+ title: t("check_fixes_hint"),
7630
+ 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",
7631
+ style: { background: "transparent", cursor: "pointer" },
7632
+ children: [
7633
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Check", size: 13 }),
7634
+ t("check_fixes", { n: noteCounts.sent })
7635
+ ]
7636
+ }
7637
+ ),
7473
7638
  showPrune && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-text-11", "data-qa-prune": "true", children: confirmPrune ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-text-mid", children: [
7474
7639
  t("keep_only_retest_q", { n: others.length }),
7475
7640
  " ",
@@ -9225,10 +9390,12 @@ function CaptureMode() {
9225
9390
  const [regionMode, setRegionMode] = React.useState(false);
9226
9391
  const [shot, setShot] = React.useState(null);
9227
9392
  const stillRef = React.useRef(null);
9393
+ const [notesFromThisShot, setNotesFromThisShot] = React.useState(0);
9228
9394
  const [shotEngine, setShotEngine] = React.useState(null);
9229
9395
  const [shotUrl, setShotUrl] = React.useState(null);
9230
9396
  const [capturing, setCapturing] = React.useState(false);
9231
9397
  const [captureError, setCaptureError] = React.useState(false);
9398
+ const [tooHeavy, setTooHeavy] = React.useState(false);
9232
9399
  const [description, setDescription] = React.useState(capturePrefill);
9233
9400
  const [severity, setSeverity] = React.useState("bug");
9234
9401
  const [targetForensics, setTargetForensics] = React.useState(void 0);
@@ -9258,6 +9425,7 @@ function CaptureMode() {
9258
9425
  const runCapture = React.useCallback(async (rect) => {
9259
9426
  setCapturing(true);
9260
9427
  setCaptureError(false);
9428
+ setTooHeavy(false);
9261
9429
  lockPageScroll();
9262
9430
  try {
9263
9431
  const outcome = await captureRegion(rect, scrollSnap.current);
@@ -9268,6 +9436,7 @@ function CaptureMode() {
9268
9436
  return;
9269
9437
  }
9270
9438
  setCaptureError(outcome.status === "failed");
9439
+ setTooHeavy(outcome.status === "too-heavy");
9271
9440
  setShotEngine(outcome.status === "ok" ? outcome.engine : null);
9272
9441
  setShot(blob);
9273
9442
  setShotUrl((old) => {
@@ -9529,7 +9698,17 @@ function CaptureMode() {
9529
9698
  canvas.removeAttribute("style");
9530
9699
  };
9531
9700
  }, [exactShots.frozenAt]);
9532
- const save = async () => {
9701
+ const resetForNextNote = React.useCallback(() => {
9702
+ setPhase("selecting");
9703
+ setSelection(null);
9704
+ setShot(null);
9705
+ setShotEngine(null);
9706
+ setDescription("");
9707
+ setSeverity("bug");
9708
+ setTargetForensics(void 0);
9709
+ setCaptureError(false);
9710
+ }, []);
9711
+ const save = async (keepGoing = false) => {
9533
9712
  if (!selection || !description.trim()) return;
9534
9713
  const target = {
9535
9714
  kind: selection.kind,
@@ -9551,6 +9730,11 @@ function CaptureMode() {
9551
9730
  severity,
9552
9731
  forensics: selection.kind === "element" ? targetForensics : void 0
9553
9732
  });
9733
+ if (keepGoing) {
9734
+ setNotesFromThisShot((n) => n + 1);
9735
+ resetForNextNote();
9736
+ return;
9737
+ }
9554
9738
  endCapture();
9555
9739
  };
9556
9740
  const popStyleFor = React.useCallback((r, cardWidth = 340) => {
@@ -9618,6 +9802,21 @@ function CaptureMode() {
9618
9802
  {
9619
9803
  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",
9620
9804
  children: [
9805
+ notesFromThisShot > 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9806
+ /* @__PURE__ */ jsxRuntime.jsxs(
9807
+ "span",
9808
+ {
9809
+ "data-qa-shot-reused": "true",
9810
+ className: "qa-flex qa-items-center qa-gap-1.5",
9811
+ style: { color: "var(--qa-success)" },
9812
+ children: [
9813
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 16 }),
9814
+ t("shot_reused", { n: String(notesFromThisShot) })
9815
+ ]
9816
+ }
9817
+ ),
9818
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-opacity-50", children: "\xB7" })
9819
+ ] }),
9621
9820
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-gap-1.5", children: [
9622
9821
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "MousePointerClick", size: 16 }),
9623
9822
  t("cap_click")
@@ -10067,6 +10266,29 @@ function CaptureMode() {
10067
10266
  )
10068
10267
  ]
10069
10268
  }
10269
+ ) : tooHeavy ? (
10270
+ // Not a failure to report — a refusal to explain, with the one
10271
+ // control that actually solves it.
10272
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3 qa-px-3 qa-text-center", children: [
10273
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-xs qa-text-mid", children: t("too_heavy") }),
10274
+ exactShots.supported ? /* @__PURE__ */ jsxRuntime.jsxs(
10275
+ "button",
10276
+ {
10277
+ type: "button",
10278
+ onClick: () => void (async () => {
10279
+ const shot2 = await photographNow();
10280
+ if (shot2 && selection) void runCapture(selection.rect);
10281
+ })(),
10282
+ title: t("exact_hint"),
10283
+ 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",
10284
+ style: { background: "transparent", cursor: "pointer" },
10285
+ children: [
10286
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 13 }),
10287
+ t("redraw_action")
10288
+ ]
10289
+ }
10290
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-10 qa-text-mid", children: t("redraw_no_engine") })
10291
+ ] })
10070
10292
  ) : captureError ? (
10071
10293
  // The render broke rather than being skipped — offer a retry
10072
10294
  // against the same selection instead of a dead-end message.
@@ -10173,7 +10395,7 @@ function CaptureMode() {
10173
10395
  value: description,
10174
10396
  onChange: (e) => setDescription(e.target.value),
10175
10397
  onKeyDown: (e) => {
10176
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
10398
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10177
10399
  if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10178
10400
  e.preventDefault();
10179
10401
  setSeverity(SEVERITIES2[Number(e.key) - 1]);
@@ -10198,17 +10420,25 @@ function CaptureMode() {
10198
10420
  ]
10199
10421
  }
10200
10422
  ),
10423
+ /* @__PURE__ */ jsxRuntime.jsxs(
10424
+ "button",
10425
+ {
10426
+ "data-qa-save-next": "true",
10427
+ onClick: () => void save(true),
10428
+ disabled: !description.trim(),
10429
+ title: t("save_next_hint"),
10430
+ 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",
10431
+ style: { background: "transparent", cursor: "pointer" },
10432
+ children: [
10433
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Plus", size: 16 }),
10434
+ t("save_next")
10435
+ ]
10436
+ }
10437
+ ),
10201
10438
  /* @__PURE__ */ jsxRuntime.jsx(
10202
10439
  "button",
10203
10440
  {
10204
- onClick: () => {
10205
- setPhase("selecting");
10206
- setSelection(null);
10207
- setShot(null);
10208
- setDescription("");
10209
- setSeverity("bug");
10210
- setTargetForensics(void 0);
10211
- },
10441
+ onClick: resetForNextNote,
10212
10442
  className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-mid",
10213
10443
  style: { background: "transparent", cursor: "pointer" },
10214
10444
  children: t("reselect")
@@ -10426,5 +10656,5 @@ function Qapture({ config }) {
10426
10656
  exports.Qapture = Qapture;
10427
10657
  exports.deleteQaDatabase = deleteQaDatabase;
10428
10658
  exports.initQaStudio = initQaStudio;
10429
- //# sourceMappingURL=chunk-E7OFH6EM.cjs.map
10430
- //# sourceMappingURL=chunk-E7OFH6EM.cjs.map
10659
+ //# sourceMappingURL=chunk-IYQ5JZJX.cjs.map
10660
+ //# sourceMappingURL=chunk-IYQ5JZJX.cjs.map