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.
@@ -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",
@@ -2786,12 +2800,21 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2786
2800
 
2787
2801
  // src/lib/capture.ts
2788
2802
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2803
+ var CHUNK_TIMEOUT_MS = 8e3;
2789
2804
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2790
2805
  var MAX_SHOT_EDGE = 1800;
2791
2806
  var WEBP_QUALITY = 0.92;
2792
2807
  var STUCK_ATTR = "data-qa-stuck";
2793
2808
  var MAX_CAPTURE_EDGE = 4e3;
2794
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
+ }
2795
2818
  function withTimeout(promise, ms) {
2796
2819
  let timer;
2797
2820
  return Promise.race([
@@ -2853,8 +2876,13 @@ function fitToBudget(canvas) {
2853
2876
  function canvasToBlob(canvas, type, quality) {
2854
2877
  return new Promise((resolve) => {
2855
2878
  if (canvas.toBlob) {
2856
- canvas.toBlob((b) => resolve(b), type, quality);
2857
- return;
2879
+ try {
2880
+ canvas.toBlob((b) => resolve(b), type, quality);
2881
+ return;
2882
+ } catch {
2883
+ resolve(null);
2884
+ return;
2885
+ }
2858
2886
  }
2859
2887
  try {
2860
2888
  const dataUrl = canvas.toDataURL(type, quality);
@@ -3041,7 +3069,9 @@ function planRender(rect, sx, sy, vw, vh) {
3041
3069
  };
3042
3070
  }
3043
3071
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3044
- 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;
3045
3075
  const { vw, vh } = viewportSize();
3046
3076
  const plan = planRender(rect, sx, sy, vw, vh);
3047
3077
  let scale = Math.min(window.devicePixelRatio || 1, 2);
@@ -3060,7 +3090,22 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3060
3090
  height: plan.render.height,
3061
3091
  scale,
3062
3092
  useCORS: true,
3063
- 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,
3064
3109
  // The page's own background, never null — see resolvePageBackground().
3065
3110
  // Passed straight to html2canvas's parser, so it gets the same
3066
3111
  // CSS Color 4 treatment the document walk applies.
@@ -3109,6 +3154,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3109
3154
  }
3110
3155
  if (prefer === "exact") return { status: "failed" };
3111
3156
  }
3157
+ const weight = documentWeight();
3158
+ if (weight > TOO_HEAVY_NODES) {
3159
+ return { status: "too-heavy", nodes: weight };
3160
+ }
3112
3161
  try {
3113
3162
  const canvas = await captureViaDom(rect, sx, sy);
3114
3163
  if (!canvas) return { status: "failed" };
@@ -3166,6 +3215,12 @@ function formatEvent(ev, t0) {
3166
3215
  }
3167
3216
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3168
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
+ }
3169
3224
  function noteToMarkdown(note, opts) {
3170
3225
  const brand = opts?.brand ?? "Qapture";
3171
3226
  const idx = opts?.index;
@@ -3223,6 +3278,14 @@ function noteToMarkdown(note, opts) {
3223
3278
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3224
3279
  });
3225
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
+ }
3226
3289
  const ctx = note.context;
3227
3290
  if (ctx) {
3228
3291
  const env = ctx.env;
@@ -3395,6 +3458,17 @@ Second, **the previous attempt is evidence.** Something about the first reading
3395
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.
3396
3459
 
3397
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.`
3398
3472
  );
3399
3473
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3400
3474
  const runArr = toStrings(p.runCommands);
@@ -3542,6 +3616,39 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3542
3616
  ` : "";
3543
3617
  const notesMd = preambleMd + "\n\n---NOTES---\n\n" + notesHeader + notesBody;
3544
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"));
3545
3652
  notes.forEach((n, i) => {
3546
3653
  if (n.screenshot && shots) {
3547
3654
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4181,6 +4288,9 @@ var GUIDE_FAILED_KEY = "guideFailed";
4181
4288
  var LOGIN_KEY = "logins";
4182
4289
  var PENDING_DELETE_KEY = "pendingDeleteIds";
4183
4290
  var EXACT_SHOTS_KEY = "exactShots";
4291
+ function exactShotsWanted(store) {
4292
+ return store.getItem(EXACT_SHOTS_KEY) !== "0";
4293
+ }
4184
4294
  var SIMPLE_MODE_KEY = "simpleMode";
4185
4295
  var COMPACT_KEY = "compactCapture";
4186
4296
  var LAST_CAMPAIGN_KEY = "lastCampaign";
@@ -4259,7 +4369,7 @@ function QaProvider({
4259
4369
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4260
4370
  );
4261
4371
  const [exactStatus, setExactStatus] = useState(() => {
4262
- if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4372
+ if (exactShotsWanted(storage)) armExactCapture();
4263
4373
  return getExactCaptureStatus();
4264
4374
  });
4265
4375
  const exactSupported = isExactCaptureSupported();
@@ -4772,7 +4882,7 @@ function QaProvider({
4772
4882
  setCapturePrefill(prefill ?? "");
4773
4883
  setCaptureActive(true);
4774
4884
  setFrozenAt(null);
4775
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4885
+ if (exactShotsWanted(storage) && isExactCaptureSupported()) {
4776
4886
  resetExactCaptureDecline();
4777
4887
  void freezeViewport().then((frame) => {
4778
4888
  setFrozenAt(frame?.takenAt ?? null);
@@ -4804,19 +4914,36 @@ function QaProvider({
4804
4914
  return next;
4805
4915
  });
4806
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]);
4807
4932
  const exportZipFn = useCallback(
4808
4933
  async (filename) => {
4809
4934
  if (!notes.length || isExporting) return;
4810
4935
  setIsExporting(true);
4811
4936
  try {
4812
- 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);
4813
4940
  } catch (err) {
4814
4941
  console.error("[QA] export failed", err);
4815
4942
  } finally {
4816
4943
  setIsExporting(false);
4817
4944
  }
4818
4945
  },
4819
- [notes, isExporting, config, guideChecked, guideSkipped]
4946
+ [notes, isExporting, config, guideChecked, guideSkipped, stampHandedOff]
4820
4947
  );
4821
4948
  const exportZipRef = useRef(async () => {
4822
4949
  });
@@ -5002,6 +5129,7 @@ function QaProvider({
5002
5129
  design: 0,
5003
5130
  enhance: 0,
5004
5131
  open: 0,
5132
+ sent: 0,
5005
5133
  fixed: 0,
5006
5134
  verified: 0,
5007
5135
  thisPage: 0
@@ -5015,6 +5143,7 @@ function QaProvider({
5015
5143
  if (status === "verified") counts.verified++;
5016
5144
  else if (status === "fixed") counts.fixed++;
5017
5145
  else counts.open++;
5146
+ if (n.handedOffAt && status !== "verified") counts.sent++;
5018
5147
  if (n.route.split("?")[0] === currentRoute) counts.thisPage++;
5019
5148
  }
5020
5149
  return counts;
@@ -5023,7 +5152,9 @@ function QaProvider({
5023
5152
  const q = filter.query.trim().toLowerCase();
5024
5153
  return notes.filter((n) => {
5025
5154
  if (filter.severity !== "all" && (n.severity ?? "bug") !== filter.severity) return false;
5026
- 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;
5027
5158
  if (filter.thisPageOnly && n.route.split("?")[0] !== currentRoute) return false;
5028
5159
  if (!q) return true;
5029
5160
  const haystack = `${n.description} ${n.route} ${n.target?.selector ?? ""} ${n.target?.text ?? ""}`;
@@ -5068,6 +5199,7 @@ function QaProvider({
5068
5199
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
5069
5200
  if (!blob) return { status: "unsupported" };
5070
5201
  const outcome = await shareZipFile(blob, name, config.brand.label);
5202
+ await stampHandedOff(notes.map((n) => n.id), stamp);
5071
5203
  if (outcome.status === "needs-gesture") {
5072
5204
  setPendingShare({ blob, filename: name });
5073
5205
  } else if (outcome.status === "unsupported") {
@@ -5079,7 +5211,7 @@ function QaProvider({
5079
5211
  } finally {
5080
5212
  setIsExporting(false);
5081
5213
  }
5082
- }, [notes, config, guideChecked, guideSkipped]);
5214
+ }, [notes, config, guideChecked, guideSkipped, stampHandedOff]);
5083
5215
  const sharePending = useCallback(async () => {
5084
5216
  const pending = pendingShare;
5085
5217
  if (!pending) return { status: "unsupported" };
@@ -5295,7 +5427,10 @@ function QaProvider({
5295
5427
  window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
5296
5428
  } catch {
5297
5429
  }
5298
- 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") {
5299
5434
  setFilterState((prev) => ({ ...prev, status: "fixed", severity: "all", thisPageOnly: false }));
5300
5435
  startWalk("notes", 0);
5301
5436
  } else if (which === "notes") {
@@ -7353,7 +7488,7 @@ function FilterChip({ chip }) {
7353
7488
  );
7354
7489
  }
7355
7490
  function NoteFilterBar() {
7356
- const { filter, setFilter, noteCounts, notes, deleteNotes, t } = useQa();
7491
+ const { filter, setFilter, noteCounts, notes, deleteNotes, startWalk, t } = useQa();
7357
7492
  const [confirmPrune, setConfirmPrune] = useState(false);
7358
7493
  if (notes.length < 3) return null;
7359
7494
  const others = notes.filter((n) => (n.status ?? "open") !== "fixed");
@@ -7395,6 +7530,18 @@ function NoteFilterBar() {
7395
7530
  tone: "accent",
7396
7531
  onClick: sev("design")
7397
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
+ }] : [],
7398
7545
  {
7399
7546
  key: "open",
7400
7547
  label: t("status_open"),
@@ -7463,6 +7610,24 @@ function NoteFilterBar() {
7463
7610
  }
7464
7611
  ),
7465
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
+ ),
7466
7631
  showPrune && /* @__PURE__ */ jsx("div", { className: "qa-text-11", "data-qa-prune": "true", children: confirmPrune ? /* @__PURE__ */ jsxs("span", { className: "qa-text-mid", children: [
7467
7632
  t("keep_only_retest_q", { n: others.length }),
7468
7633
  " ",
@@ -9218,10 +9383,12 @@ function CaptureMode() {
9218
9383
  const [regionMode, setRegionMode] = useState(false);
9219
9384
  const [shot, setShot] = useState(null);
9220
9385
  const stillRef = useRef(null);
9386
+ const [notesFromThisShot, setNotesFromThisShot] = useState(0);
9221
9387
  const [shotEngine, setShotEngine] = useState(null);
9222
9388
  const [shotUrl, setShotUrl] = useState(null);
9223
9389
  const [capturing, setCapturing] = useState(false);
9224
9390
  const [captureError, setCaptureError] = useState(false);
9391
+ const [tooHeavy, setTooHeavy] = useState(false);
9225
9392
  const [description, setDescription] = useState(capturePrefill);
9226
9393
  const [severity, setSeverity] = useState("bug");
9227
9394
  const [targetForensics, setTargetForensics] = useState(void 0);
@@ -9251,6 +9418,7 @@ function CaptureMode() {
9251
9418
  const runCapture = useCallback(async (rect) => {
9252
9419
  setCapturing(true);
9253
9420
  setCaptureError(false);
9421
+ setTooHeavy(false);
9254
9422
  lockPageScroll();
9255
9423
  try {
9256
9424
  const outcome = await captureRegion(rect, scrollSnap.current);
@@ -9261,6 +9429,7 @@ function CaptureMode() {
9261
9429
  return;
9262
9430
  }
9263
9431
  setCaptureError(outcome.status === "failed");
9432
+ setTooHeavy(outcome.status === "too-heavy");
9264
9433
  setShotEngine(outcome.status === "ok" ? outcome.engine : null);
9265
9434
  setShot(blob);
9266
9435
  setShotUrl((old) => {
@@ -9522,7 +9691,17 @@ function CaptureMode() {
9522
9691
  canvas.removeAttribute("style");
9523
9692
  };
9524
9693
  }, [exactShots.frozenAt]);
9525
- const save = async () => {
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) => {
9526
9705
  if (!selection || !description.trim()) return;
9527
9706
  const target = {
9528
9707
  kind: selection.kind,
@@ -9544,6 +9723,11 @@ function CaptureMode() {
9544
9723
  severity,
9545
9724
  forensics: selection.kind === "element" ? targetForensics : void 0
9546
9725
  });
9726
+ if (keepGoing) {
9727
+ setNotesFromThisShot((n) => n + 1);
9728
+ resetForNextNote();
9729
+ return;
9730
+ }
9547
9731
  endCapture();
9548
9732
  };
9549
9733
  const popStyleFor = useCallback((r, cardWidth = 340) => {
@@ -9611,6 +9795,21 @@ function CaptureMode() {
9611
9795
  {
9612
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",
9613
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
+ ] }),
9614
9813
  /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-1.5", children: [
9615
9814
  /* @__PURE__ */ jsx(Icon, { name: "MousePointerClick", size: 16 }),
9616
9815
  t("cap_click")
@@ -10060,6 +10259,29 @@ function CaptureMode() {
10060
10259
  )
10061
10260
  ]
10062
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
+ ] })
10063
10285
  ) : captureError ? (
10064
10286
  // The render broke rather than being skipped — offer a retry
10065
10287
  // against the same selection instead of a dead-end message.
@@ -10166,7 +10388,7 @@ function CaptureMode() {
10166
10388
  value: description,
10167
10389
  onChange: (e) => setDescription(e.target.value),
10168
10390
  onKeyDown: (e) => {
10169
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
10391
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10170
10392
  if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10171
10393
  e.preventDefault();
10172
10394
  setSeverity(SEVERITIES2[Number(e.key) - 1]);
@@ -10191,17 +10413,25 @@ function CaptureMode() {
10191
10413
  ]
10192
10414
  }
10193
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
+ ),
10194
10431
  /* @__PURE__ */ jsx(
10195
10432
  "button",
10196
10433
  {
10197
- onClick: () => {
10198
- setPhase("selecting");
10199
- setSelection(null);
10200
- setShot(null);
10201
- setDescription("");
10202
- setSeverity("bug");
10203
- setTargetForensics(void 0);
10204
- },
10434
+ onClick: resetForNextNote,
10205
10435
  className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-mid",
10206
10436
  style: { background: "transparent", cursor: "pointer" },
10207
10437
  children: t("reselect")
@@ -10417,5 +10647,5 @@ function Qapture({ config }) {
10417
10647
  }
10418
10648
 
10419
10649
  export { Qapture, deleteQaDatabase, initQaStudio };
10420
- //# sourceMappingURL=chunk-RJV6FTIB.js.map
10421
- //# sourceMappingURL=chunk-RJV6FTIB.js.map
10650
+ //# sourceMappingURL=chunk-JGSKPW7Y.js.map
10651
+ //# sourceMappingURL=chunk-JGSKPW7Y.js.map
package/dist/index.cjs CHANGED
@@ -1,24 +1,24 @@
1
1
  'use strict';
2
2
 
3
- var chunkE7OFH6EM_cjs = require('./chunk-E7OFH6EM.cjs');
3
+ var chunkIYQ5JZJX_cjs = require('./chunk-IYQ5JZJX.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "QaStudio", {
8
8
  enumerable: true,
9
- get: function () { return chunkE7OFH6EM_cjs.Qapture; }
9
+ get: function () { return chunkIYQ5JZJX_cjs.Qapture; }
10
10
  });
11
11
  Object.defineProperty(exports, "Qapture", {
12
12
  enumerable: true,
13
- get: function () { return chunkE7OFH6EM_cjs.Qapture; }
13
+ get: function () { return chunkIYQ5JZJX_cjs.Qapture; }
14
14
  });
15
15
  Object.defineProperty(exports, "deleteQaDatabase", {
16
16
  enumerable: true,
17
- get: function () { return chunkE7OFH6EM_cjs.deleteQaDatabase; }
17
+ get: function () { return chunkIYQ5JZJX_cjs.deleteQaDatabase; }
18
18
  });
19
19
  Object.defineProperty(exports, "initQaStudio", {
20
20
  enumerable: true,
21
- get: function () { return chunkE7OFH6EM_cjs.initQaStudio; }
21
+ get: function () { return chunkIYQ5JZJX_cjs.initQaStudio; }
22
22
  });
23
23
  //# sourceMappingURL=index.cjs.map
24
24
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-RJV6FTIB.js';
1
+ export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-JGSKPW7Y.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/next.cjs CHANGED
@@ -1,21 +1,21 @@
1
1
  "use client";
2
2
  'use strict';
3
3
 
4
- var chunkE7OFH6EM_cjs = require('./chunk-E7OFH6EM.cjs');
4
+ var chunkIYQ5JZJX_cjs = require('./chunk-IYQ5JZJX.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "QaStudio", {
9
9
  enumerable: true,
10
- get: function () { return chunkE7OFH6EM_cjs.Qapture; }
10
+ get: function () { return chunkIYQ5JZJX_cjs.Qapture; }
11
11
  });
12
12
  Object.defineProperty(exports, "Qapture", {
13
13
  enumerable: true,
14
- get: function () { return chunkE7OFH6EM_cjs.Qapture; }
14
+ get: function () { return chunkIYQ5JZJX_cjs.Qapture; }
15
15
  });
16
16
  Object.defineProperty(exports, "initQaStudio", {
17
17
  enumerable: true,
18
- get: function () { return chunkE7OFH6EM_cjs.initQaStudio; }
18
+ get: function () { return chunkIYQ5JZJX_cjs.initQaStudio; }
19
19
  });
20
20
  //# sourceMappingURL=next.cjs.map
21
21
  //# sourceMappingURL=next.cjs.map
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-RJV6FTIB.js';
2
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-JGSKPW7Y.js';
3
3
  //# sourceMappingURL=next.js.map
4
4
  //# sourceMappingURL=next.js.map
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkE7OFH6EM_cjs = require('./chunk-E7OFH6EM.cjs');
3
+ var chunkIYQ5JZJX_cjs = require('./chunk-IYQ5JZJX.cjs');
4
4
 
5
5
  // src/standalone.ts
6
6
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
@@ -38,7 +38,7 @@ if (typeof window !== "undefined" && typeof customElements !== "undefined" && !c
38
38
 
39
39
  Object.defineProperty(exports, "initQaStudio", {
40
40
  enumerable: true,
41
- get: function () { return chunkE7OFH6EM_cjs.initQaStudio; }
41
+ get: function () { return chunkIYQ5JZJX_cjs.initQaStudio; }
42
42
  });
43
43
  //# sourceMappingURL=standalone.cjs.map
44
44
  //# sourceMappingURL=standalone.cjs.map
@@ -1,4 +1,4 @@
1
- export { initQaStudio } from './chunk-RJV6FTIB.js';
1
+ export { initQaStudio } from './chunk-JGSKPW7Y.js';
2
2
 
3
3
  // src/standalone.ts
4
4
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qapture2",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Drop-in, AI-aware in-browser QA capture widget. A tester annotates the live app (element/region + auto-screenshot + note), tracks a graded testing journey, and exports a ZIP your own coding agent reads. Ships zero AI.",
5
5
  "type": "module",
6
6
  "license": "MIT",