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.
@@ -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",
@@ -2376,6 +2390,25 @@ function getExactCaptureStatus() {
2376
2390
  function getFrozenFrame() {
2377
2391
  return frozen;
2378
2392
  }
2393
+ var STILL_REUSE_MS = 9e4;
2394
+ var frozenAtScrollX = 0;
2395
+ var frozenAtScrollY = 0;
2396
+ var frozenAtPath = "";
2397
+ function stillIsCurrent() {
2398
+ if (!frozen) return false;
2399
+ if (typeof window === "undefined") return false;
2400
+ if (Date.now() - frozen.takenAt > STILL_REUSE_MS) return false;
2401
+ if (window.scrollX !== frozenAtScrollX || window.scrollY !== frozenAtScrollY) return false;
2402
+ if (window.location.pathname + window.location.search !== frozenAtPath) return false;
2403
+ const vw = document.documentElement.clientWidth || window.innerWidth;
2404
+ const vh = document.documentElement.clientHeight || window.innerHeight;
2405
+ if (vw !== frozen.viewportWidth || vh !== frozen.viewportHeight) return false;
2406
+ return true;
2407
+ }
2408
+ async function freezeOrReuse() {
2409
+ if (stillIsCurrent()) return frozen;
2410
+ return freezeViewport();
2411
+ }
2379
2412
  function releaseFrozenFrame() {
2380
2413
  if (frozen) {
2381
2414
  frozen.canvas.width = 0;
@@ -2620,6 +2653,9 @@ async function freezeViewport() {
2620
2653
  viewportHeight: vh,
2621
2654
  takenAt: Date.now()
2622
2655
  };
2656
+ frozenAtScrollX = window.scrollX;
2657
+ frozenAtScrollY = window.scrollY;
2658
+ frozenAtPath = window.location.pathname + window.location.search;
2623
2659
  lastMode = mode;
2624
2660
  return frozen;
2625
2661
  } catch {
@@ -2793,12 +2829,21 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2793
2829
 
2794
2830
  // src/lib/capture.ts
2795
2831
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2832
+ var CHUNK_TIMEOUT_MS = 8e3;
2796
2833
  var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2797
2834
  var MAX_SHOT_EDGE = 1800;
2798
2835
  var WEBP_QUALITY = 0.92;
2799
2836
  var STUCK_ATTR = "data-qa-stuck";
2800
2837
  var MAX_CAPTURE_EDGE = 4e3;
2801
2838
  var MAX_RENDER_DEVICE_PIXELS = 16e6;
2839
+ var TOO_HEAVY_NODES = 6e3;
2840
+ function documentWeight() {
2841
+ try {
2842
+ return document.getElementsByTagName("*").length;
2843
+ } catch {
2844
+ return 0;
2845
+ }
2846
+ }
2802
2847
  function withTimeout(promise, ms) {
2803
2848
  let timer;
2804
2849
  return Promise.race([
@@ -2860,8 +2905,13 @@ function fitToBudget(canvas) {
2860
2905
  function canvasToBlob(canvas, type, quality) {
2861
2906
  return new Promise((resolve) => {
2862
2907
  if (canvas.toBlob) {
2863
- canvas.toBlob((b) => resolve(b), type, quality);
2864
- return;
2908
+ try {
2909
+ canvas.toBlob((b) => resolve(b), type, quality);
2910
+ return;
2911
+ } catch {
2912
+ resolve(null);
2913
+ return;
2914
+ }
2865
2915
  }
2866
2916
  try {
2867
2917
  const dataUrl = canvas.toDataURL(type, quality);
@@ -3048,7 +3098,9 @@ function planRender(rect, sx, sy, vw, vh) {
3048
3098
  };
3049
3099
  }
3050
3100
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3051
- const { default: html2canvas } = await import('html2canvas');
3101
+ const mod = await withTimeout(import('html2canvas'), CHUNK_TIMEOUT_MS);
3102
+ if (!mod) return null;
3103
+ const { default: html2canvas } = mod;
3052
3104
  const { vw, vh } = viewportSize();
3053
3105
  const plan = planRender(rect, sx, sy, vw, vh);
3054
3106
  let scale = Math.min(window.devicePixelRatio || 1, 2);
@@ -3067,7 +3119,22 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
3067
3119
  height: plan.render.height,
3068
3120
  scale,
3069
3121
  useCORS: true,
3070
- allowTaint: true,
3122
+ // MUST stay false. `allowTaint: true` tells html2canvas to draw
3123
+ // cross-origin images it could not fetch with CORS -- which succeeds,
3124
+ // and TAINTS the canvas. A tainted canvas throws SecurityError from
3125
+ // toBlob/toDataURL, so the render finishes and then the encode dies,
3126
+ // and the tester gets "couldn't capture" with nothing to look at.
3127
+ //
3128
+ // That is one image away on any real page: a map tile, a news
3129
+ // thumbnail, an avatar, an ad, a CDN logo. It made image-heavy pages
3130
+ // fail 100% of the time while a plain page worked, which is exactly
3131
+ // the "it works for you and not for me" this kept producing.
3132
+ //
3133
+ // False is strictly better: html2canvas SKIPS the images it cannot
3134
+ // read, so those come out blank and everything else comes out right.
3135
+ // A screenshot with two grey rectangles in it is worth having. A
3136
+ // SecurityError is not.
3137
+ allowTaint: false,
3071
3138
  // The page's own background, never null — see resolvePageBackground().
3072
3139
  // Passed straight to html2canvas's parser, so it gets the same
3073
3140
  // CSS Color 4 treatment the document walk applies.
@@ -3116,6 +3183,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3116
3183
  }
3117
3184
  if (prefer === "exact") return { status: "failed" };
3118
3185
  }
3186
+ const weight = documentWeight();
3187
+ if (weight > TOO_HEAVY_NODES) {
3188
+ return { status: "too-heavy", nodes: weight };
3189
+ }
3119
3190
  try {
3120
3191
  const canvas = await captureViaDom(rect, sx, sy);
3121
3192
  if (!canvas) return { status: "failed" };
@@ -3173,6 +3244,12 @@ function formatEvent(ev, t0) {
3173
3244
  }
3174
3245
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3175
3246
  }
3247
+ function noteCheckLine(note, index) {
3248
+ const where = oneLine(note.route) || "/";
3249
+ const what = oneLine(note.description) || "(no description)";
3250
+ const trimmed = what.length > 160 ? `${what.slice(0, 157)}...` : what;
3251
+ return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3252
+ }
3176
3253
  function noteToMarkdown(note, opts) {
3177
3254
  const brand = opts?.brand ?? "Qapture";
3178
3255
  const idx = opts?.index;
@@ -3230,6 +3307,14 @@ function noteToMarkdown(note, opts) {
3230
3307
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3231
3308
  });
3232
3309
  }
3310
+ if (idx != null) {
3311
+ lines.push("");
3312
+ lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3313
+ lines.push("");
3314
+ 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?*");
3315
+ lines.push("");
3316
+ 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\`.`);
3317
+ }
3233
3318
  const ctx = note.context;
3234
3319
  if (ctx) {
3235
3320
  const env = ctx.env;
@@ -3402,6 +3487,17 @@ Second, **the previous attempt is evidence.** Something about the first reading
3402
3487
  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
3488
 
3404
3489
  And if a point is phrased as a question rather than a report, **answer it \u2014 do not change code on a maybe.**`
3490
+ );
3491
+ sections.push(
3492
+ `## You Are Being Graded On This
3493
+
3494
+ 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.
3495
+
3496
+ **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.
3497
+
3498
+ **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.
3499
+
3500
+ Hand \`verify.md\` back with the work.`
3405
3501
  );
3406
3502
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3407
3503
  const runArr = toStrings(p.runCommands);
@@ -3549,6 +3645,39 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3549
3645
  ` : "";
3550
3646
  const notesMd = preambleMd + "\n\n---NOTES---\n\n" + notesHeader + notesBody;
3551
3647
  zip.file("notes.md", notesMd);
3648
+ zip.file("verify.md", [
3649
+ `# ${brandLabel} \u2014 checks to satisfy`,
3650
+ "",
3651
+ `Exported: ${stamp}`,
3652
+ `Checks: ${notes.length}`,
3653
+ "",
3654
+ "Each line below is one thing the tester asked for. The full ask, the",
3655
+ "screenshot, the page and the element are in `notes.md` under the matching",
3656
+ '"Point" heading.',
3657
+ "",
3658
+ "**What is expected of you**",
3659
+ "",
3660
+ "1. Do the work in `notes.md`.",
3661
+ "2. Tick a box here only when the check is true on a fresh load of the page",
3662
+ " named beside it, with the tester doing nothing extra.",
3663
+ "3. Leave a box unticked and write one line under it saying why, if you",
3664
+ " could not do it or disagree with it. An unticked box with a reason is a",
3665
+ " good answer; a ticked box that is not true is the only bad one.",
3666
+ "4. Hand this file back with the work.",
3667
+ "",
3668
+ "The tester will then be walked back through these same checks on the real",
3669
+ "page, in this order, and asked about each one. Anything they say is still",
3670
+ "wrong comes back to you as round two, with their new words attached.",
3671
+ "",
3672
+ "---",
3673
+ "",
3674
+ ...notes.map((n, i) => noteCheckLine(n, i + 1)),
3675
+ "",
3676
+ "---",
3677
+ "",
3678
+ "Re-open the walkthrough on the tester's machine with `?qa=walk:verify`.",
3679
+ ""
3680
+ ].join("\n"));
3552
3681
  notes.forEach((n, i) => {
3553
3682
  if (n.screenshot && shots) {
3554
3683
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4188,6 +4317,9 @@ var GUIDE_FAILED_KEY = "guideFailed";
4188
4317
  var LOGIN_KEY = "logins";
4189
4318
  var PENDING_DELETE_KEY = "pendingDeleteIds";
4190
4319
  var EXACT_SHOTS_KEY = "exactShots";
4320
+ function exactShotsWanted(store) {
4321
+ return store.getItem(EXACT_SHOTS_KEY) !== "0";
4322
+ }
4191
4323
  var SIMPLE_MODE_KEY = "simpleMode";
4192
4324
  var COMPACT_KEY = "compactCapture";
4193
4325
  var LAST_CAMPAIGN_KEY = "lastCampaign";
@@ -4228,6 +4360,7 @@ function QaProvider({
4228
4360
  React.useEffect(() => {
4229
4361
  notesRef.current = notes;
4230
4362
  }, [notes]);
4363
+ const staleStillTimer = React.useRef(null);
4231
4364
  const applyNotes = React.useCallback((updater) => {
4232
4365
  const next = updater(notesRef.current);
4233
4366
  notesRef.current = next;
@@ -4266,7 +4399,7 @@ function QaProvider({
4266
4399
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4267
4400
  );
4268
4401
  const [exactStatus, setExactStatus] = React.useState(() => {
4269
- if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4402
+ if (exactShotsWanted(storage)) armExactCapture();
4270
4403
  return getExactCaptureStatus();
4271
4404
  });
4272
4405
  const exactSupported = isExactCaptureSupported();
@@ -4771,17 +4904,22 @@ function QaProvider({
4771
4904
  flushPendingDeletes();
4772
4905
  for (const timer of noticeTimers.current.values()) clearTimeout(timer);
4773
4906
  noticeTimers.current.clear();
4907
+ if (staleStillTimer.current) {
4908
+ clearTimeout(staleStillTimer.current);
4909
+ staleStillTimer.current = null;
4910
+ }
4774
4911
  releaseFrozenFrame();
4775
4912
  };
4776
4913
  }, [flushPendingDeletes]);
4914
+ const STILL_HOLD_MS = 1e5;
4777
4915
  const startCapture = React.useCallback((prefill) => {
4778
4916
  setIsOpen(false);
4779
4917
  setCapturePrefill(prefill ?? "");
4780
4918
  setCaptureActive(true);
4781
- setFrozenAt(null);
4782
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4919
+ if (!stillIsCurrent()) setFrozenAt(null);
4920
+ if (exactShotsWanted(storage) && isExactCaptureSupported()) {
4783
4921
  resetExactCaptureDecline();
4784
- void freezeViewport().then((frame) => {
4922
+ void freezeOrReuse().then((frame) => {
4785
4923
  setFrozenAt(frame?.takenAt ?? null);
4786
4924
  setExactStatus(getExactCaptureStatus());
4787
4925
  });
@@ -4789,8 +4927,11 @@ function QaProvider({
4789
4927
  }, [storage]);
4790
4928
  const endCapture = React.useCallback((reopen = true) => {
4791
4929
  setCaptureActive(false);
4792
- releaseFrozenFrame();
4793
- setFrozenAt(null);
4930
+ if (staleStillTimer.current) clearTimeout(staleStillTimer.current);
4931
+ staleStillTimer.current = setTimeout(() => {
4932
+ releaseFrozenFrame();
4933
+ staleStillTimer.current = null;
4934
+ }, STILL_HOLD_MS);
4794
4935
  if (reopen) setIsOpen(true);
4795
4936
  }, []);
4796
4937
  const toggleGuide = React.useCallback((key) => {
@@ -4811,19 +4952,36 @@ function QaProvider({
4811
4952
  return next;
4812
4953
  });
4813
4954
  }, [storage]);
4955
+ const stampHandedOff = React.useCallback(async (ids, at) => {
4956
+ if (!ids.length) return;
4957
+ const idSet = new Set(ids);
4958
+ const touched = [];
4959
+ applyNotes(
4960
+ (prev) => prev.map((n) => {
4961
+ if (!idSet.has(n.id)) return n;
4962
+ if ((n.status ?? "open") === "verified") return n;
4963
+ const next = { ...n, handedOffAt: at };
4964
+ touched.push(next);
4965
+ return next;
4966
+ })
4967
+ );
4968
+ for (const note of touched) await idb.put(note);
4969
+ }, [applyNotes, idb]);
4814
4970
  const exportZipFn = React.useCallback(
4815
4971
  async (filename) => {
4816
4972
  if (!notes.length || isExporting) return;
4817
4973
  setIsExporting(true);
4818
4974
  try {
4819
- await buildAndDownloadZip(notes, nowIso(), filename, config, guideChecked, guideSkipped);
4975
+ const stamp = nowIso();
4976
+ await buildAndDownloadZip(notes, stamp, filename, config, guideChecked, guideSkipped);
4977
+ await stampHandedOff(notes.map((n) => n.id), stamp);
4820
4978
  } catch (err) {
4821
4979
  console.error("[QA] export failed", err);
4822
4980
  } finally {
4823
4981
  setIsExporting(false);
4824
4982
  }
4825
4983
  },
4826
- [notes, isExporting, config, guideChecked, guideSkipped]
4984
+ [notes, isExporting, config, guideChecked, guideSkipped, stampHandedOff]
4827
4985
  );
4828
4986
  const exportZipRef = React.useRef(async () => {
4829
4987
  });
@@ -5009,6 +5167,7 @@ function QaProvider({
5009
5167
  design: 0,
5010
5168
  enhance: 0,
5011
5169
  open: 0,
5170
+ sent: 0,
5012
5171
  fixed: 0,
5013
5172
  verified: 0,
5014
5173
  thisPage: 0
@@ -5022,6 +5181,7 @@ function QaProvider({
5022
5181
  if (status === "verified") counts.verified++;
5023
5182
  else if (status === "fixed") counts.fixed++;
5024
5183
  else counts.open++;
5184
+ if (n.handedOffAt && status !== "verified") counts.sent++;
5025
5185
  if (n.route.split("?")[0] === currentRoute) counts.thisPage++;
5026
5186
  }
5027
5187
  return counts;
@@ -5030,7 +5190,9 @@ function QaProvider({
5030
5190
  const q = filter.query.trim().toLowerCase();
5031
5191
  return notes.filter((n) => {
5032
5192
  if (filter.severity !== "all" && (n.severity ?? "bug") !== filter.severity) return false;
5033
- if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5193
+ if (filter.status === "sent") {
5194
+ if (!n.handedOffAt || (n.status ?? "open") === "verified") return false;
5195
+ } else if (filter.status !== "all" && (n.status ?? "open") !== filter.status) return false;
5034
5196
  if (filter.thisPageOnly && n.route.split("?")[0] !== currentRoute) return false;
5035
5197
  if (!q) return true;
5036
5198
  const haystack = `${n.description} ${n.route} ${n.target?.selector ?? ""} ${n.target?.text ?? ""}`;
@@ -5075,6 +5237,7 @@ function QaProvider({
5075
5237
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
5076
5238
  if (!blob) return { status: "unsupported" };
5077
5239
  const outcome = await shareZipFile(blob, name, config.brand.label);
5240
+ await stampHandedOff(notes.map((n) => n.id), stamp);
5078
5241
  if (outcome.status === "needs-gesture") {
5079
5242
  setPendingShare({ blob, filename: name });
5080
5243
  } else if (outcome.status === "unsupported") {
@@ -5086,7 +5249,7 @@ function QaProvider({
5086
5249
  } finally {
5087
5250
  setIsExporting(false);
5088
5251
  }
5089
- }, [notes, config, guideChecked, guideSkipped]);
5252
+ }, [notes, config, guideChecked, guideSkipped, stampHandedOff]);
5090
5253
  const sharePending = React.useCallback(async () => {
5091
5254
  const pending = pendingShare;
5092
5255
  if (!pending) return { status: "unsupported" };
@@ -5302,7 +5465,10 @@ function QaProvider({
5302
5465
  window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
5303
5466
  } catch {
5304
5467
  }
5305
- if (which === "retest") {
5468
+ if (which === "verify") {
5469
+ setFilterState((prev) => ({ ...prev, status: "sent", severity: "all", thisPageOnly: false }));
5470
+ startWalk("notes", 0);
5471
+ } else if (which === "retest") {
5306
5472
  setFilterState((prev) => ({ ...prev, status: "fixed", severity: "all", thisPageOnly: false }));
5307
5473
  startWalk("notes", 0);
5308
5474
  } else if (which === "notes") {
@@ -7360,7 +7526,7 @@ function FilterChip({ chip }) {
7360
7526
  );
7361
7527
  }
7362
7528
  function NoteFilterBar() {
7363
- const { filter, setFilter, noteCounts, notes, deleteNotes, t } = useQa();
7529
+ const { filter, setFilter, noteCounts, notes, deleteNotes, startWalk, t } = useQa();
7364
7530
  const [confirmPrune, setConfirmPrune] = React.useState(false);
7365
7531
  if (notes.length < 3) return null;
7366
7532
  const others = notes.filter((n) => (n.status ?? "open") !== "fixed");
@@ -7402,6 +7568,18 @@ function NoteFilterBar() {
7402
7568
  tone: "accent",
7403
7569
  onClick: sev("design")
7404
7570
  },
7571
+ // Only ever shown once something has actually been sent. A chip reading
7572
+ // "Sent 0" on a first run is noise; the same chip reading "Sent 6" the
7573
+ // morning after an export is the one thing the tester came back for.
7574
+ ...noteCounts.sent > 0 ? [{
7575
+ key: "sent",
7576
+ label: t("status_sent"),
7577
+ count: noteCounts.sent,
7578
+ active: filter.status === "sent",
7579
+ icon: "Camera",
7580
+ tone: "accent",
7581
+ onClick: () => setFilter({ status: "sent", severity: "all", thisPageOnly: false })
7582
+ }] : [],
7405
7583
  {
7406
7584
  key: "open",
7407
7585
  label: t("status_open"),
@@ -7470,6 +7648,24 @@ function NoteFilterBar() {
7470
7648
  }
7471
7649
  ),
7472
7650
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-flex qa-flex-wrap qa-gap-1", children: chips.map((chip) => /* @__PURE__ */ jsxRuntime.jsx(FilterChip, { chip }, chip.key)) }),
7651
+ noteCounts.sent > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
7652
+ "button",
7653
+ {
7654
+ type: "button",
7655
+ "data-qa-verify-walk": "true",
7656
+ onClick: () => {
7657
+ setFilter({ status: "sent", severity: "all", thisPageOnly: false });
7658
+ startWalk("notes", 0);
7659
+ },
7660
+ title: t("check_fixes_hint"),
7661
+ 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",
7662
+ style: { background: "transparent", cursor: "pointer" },
7663
+ children: [
7664
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Check", size: 13 }),
7665
+ t("check_fixes", { n: noteCounts.sent })
7666
+ ]
7667
+ }
7668
+ ),
7473
7669
  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
7670
  t("keep_only_retest_q", { n: others.length }),
7475
7671
  " ",
@@ -9225,10 +9421,12 @@ function CaptureMode() {
9225
9421
  const [regionMode, setRegionMode] = React.useState(false);
9226
9422
  const [shot, setShot] = React.useState(null);
9227
9423
  const stillRef = React.useRef(null);
9424
+ const [notesFromThisShot, setNotesFromThisShot] = React.useState(0);
9228
9425
  const [shotEngine, setShotEngine] = React.useState(null);
9229
9426
  const [shotUrl, setShotUrl] = React.useState(null);
9230
9427
  const [capturing, setCapturing] = React.useState(false);
9231
9428
  const [captureError, setCaptureError] = React.useState(false);
9429
+ const [tooHeavy, setTooHeavy] = React.useState(false);
9232
9430
  const [description, setDescription] = React.useState(capturePrefill);
9233
9431
  const [severity, setSeverity] = React.useState("bug");
9234
9432
  const [targetForensics, setTargetForensics] = React.useState(void 0);
@@ -9258,6 +9456,7 @@ function CaptureMode() {
9258
9456
  const runCapture = React.useCallback(async (rect) => {
9259
9457
  setCapturing(true);
9260
9458
  setCaptureError(false);
9459
+ setTooHeavy(false);
9261
9460
  lockPageScroll();
9262
9461
  try {
9263
9462
  const outcome = await captureRegion(rect, scrollSnap.current);
@@ -9268,6 +9467,7 @@ function CaptureMode() {
9268
9467
  return;
9269
9468
  }
9270
9469
  setCaptureError(outcome.status === "failed");
9470
+ setTooHeavy(outcome.status === "too-heavy");
9271
9471
  setShotEngine(outcome.status === "ok" ? outcome.engine : null);
9272
9472
  setShot(blob);
9273
9473
  setShotUrl((old) => {
@@ -9529,7 +9729,17 @@ function CaptureMode() {
9529
9729
  canvas.removeAttribute("style");
9530
9730
  };
9531
9731
  }, [exactShots.frozenAt]);
9532
- const save = async () => {
9732
+ const resetForNextNote = React.useCallback(() => {
9733
+ setPhase("selecting");
9734
+ setSelection(null);
9735
+ setShot(null);
9736
+ setShotEngine(null);
9737
+ setDescription("");
9738
+ setSeverity("bug");
9739
+ setTargetForensics(void 0);
9740
+ setCaptureError(false);
9741
+ }, []);
9742
+ const save = async (keepGoing = false) => {
9533
9743
  if (!selection || !description.trim()) return;
9534
9744
  const target = {
9535
9745
  kind: selection.kind,
@@ -9551,6 +9761,11 @@ function CaptureMode() {
9551
9761
  severity,
9552
9762
  forensics: selection.kind === "element" ? targetForensics : void 0
9553
9763
  });
9764
+ if (keepGoing) {
9765
+ setNotesFromThisShot((n) => n + 1);
9766
+ resetForNextNote();
9767
+ return;
9768
+ }
9554
9769
  endCapture();
9555
9770
  };
9556
9771
  const popStyleFor = React.useCallback((r, cardWidth = 340) => {
@@ -9618,6 +9833,21 @@ function CaptureMode() {
9618
9833
  {
9619
9834
  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
9835
  children: [
9836
+ notesFromThisShot > 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9837
+ /* @__PURE__ */ jsxRuntime.jsxs(
9838
+ "span",
9839
+ {
9840
+ "data-qa-shot-reused": "true",
9841
+ className: "qa-flex qa-items-center qa-gap-1.5",
9842
+ style: { color: "var(--qa-success)" },
9843
+ children: [
9844
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 16 }),
9845
+ t("shot_reused", { n: String(notesFromThisShot) })
9846
+ ]
9847
+ }
9848
+ ),
9849
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-opacity-50", children: "\xB7" })
9850
+ ] }),
9621
9851
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-gap-1.5", children: [
9622
9852
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "MousePointerClick", size: 16 }),
9623
9853
  t("cap_click")
@@ -10067,6 +10297,29 @@ function CaptureMode() {
10067
10297
  )
10068
10298
  ]
10069
10299
  }
10300
+ ) : tooHeavy ? (
10301
+ // Not a failure to report — a refusal to explain, with the one
10302
+ // control that actually solves it.
10303
+ /* @__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: [
10304
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-xs qa-text-mid", children: t("too_heavy") }),
10305
+ exactShots.supported ? /* @__PURE__ */ jsxRuntime.jsxs(
10306
+ "button",
10307
+ {
10308
+ type: "button",
10309
+ onClick: () => void (async () => {
10310
+ const shot2 = await photographNow();
10311
+ if (shot2 && selection) void runCapture(selection.rect);
10312
+ })(),
10313
+ title: t("exact_hint"),
10314
+ 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",
10315
+ style: { background: "transparent", cursor: "pointer" },
10316
+ children: [
10317
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 13 }),
10318
+ t("redraw_action")
10319
+ ]
10320
+ }
10321
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-10 qa-text-mid", children: t("redraw_no_engine") })
10322
+ ] })
10070
10323
  ) : captureError ? (
10071
10324
  // The render broke rather than being skipped — offer a retry
10072
10325
  // against the same selection instead of a dead-end message.
@@ -10173,7 +10426,7 @@ function CaptureMode() {
10173
10426
  value: description,
10174
10427
  onChange: (e) => setDescription(e.target.value),
10175
10428
  onKeyDown: (e) => {
10176
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save();
10429
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10177
10430
  if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10178
10431
  e.preventDefault();
10179
10432
  setSeverity(SEVERITIES2[Number(e.key) - 1]);
@@ -10198,17 +10451,25 @@ function CaptureMode() {
10198
10451
  ]
10199
10452
  }
10200
10453
  ),
10454
+ /* @__PURE__ */ jsxRuntime.jsxs(
10455
+ "button",
10456
+ {
10457
+ "data-qa-save-next": "true",
10458
+ onClick: () => void save(true),
10459
+ disabled: !description.trim(),
10460
+ title: t("save_next_hint"),
10461
+ 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",
10462
+ style: { background: "transparent", cursor: "pointer" },
10463
+ children: [
10464
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Plus", size: 16 }),
10465
+ t("save_next")
10466
+ ]
10467
+ }
10468
+ ),
10201
10469
  /* @__PURE__ */ jsxRuntime.jsx(
10202
10470
  "button",
10203
10471
  {
10204
- onClick: () => {
10205
- setPhase("selecting");
10206
- setSelection(null);
10207
- setShot(null);
10208
- setDescription("");
10209
- setSeverity("bug");
10210
- setTargetForensics(void 0);
10211
- },
10472
+ onClick: resetForNextNote,
10212
10473
  className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-3 qa-py-2 qa-text-sm qa-text-mid",
10213
10474
  style: { background: "transparent", cursor: "pointer" },
10214
10475
  children: t("reselect")
@@ -10426,5 +10687,5 @@ function Qapture({ config }) {
10426
10687
  exports.Qapture = Qapture;
10427
10688
  exports.deleteQaDatabase = deleteQaDatabase;
10428
10689
  exports.initQaStudio = initQaStudio;
10429
- //# sourceMappingURL=chunk-E7OFH6EM.cjs.map
10430
- //# sourceMappingURL=chunk-E7OFH6EM.cjs.map
10690
+ //# sourceMappingURL=chunk-OSYYMS6R.cjs.map
10691
+ //# sourceMappingURL=chunk-OSYYMS6R.cjs.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 chunkOSYYMS6R_cjs = require('./chunk-OSYYMS6R.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 chunkOSYYMS6R_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 chunkOSYYMS6R_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 chunkOSYYMS6R_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 chunkOSYYMS6R_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-32LKZ2PG.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map