qapture2 0.9.0 → 0.10.0

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.
@@ -166,10 +166,12 @@ function deleteQaDatabase(namespace) {
166
166
  // src/config/schema.ts
167
167
  var DEFAULTS = {
168
168
  namespace: "qapture",
169
+ shotPort: 7017,
169
170
  brandLabel: "Qapture",
170
171
  loginField: { en: "Username", ar: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645" },
171
172
  rtl: false,
172
173
  beta: false,
174
+ collector: null,
173
175
  visible: void 0,
174
176
  alwaysVisible: false,
175
177
  hotkey: "shift+alt+q",
@@ -335,6 +337,7 @@ function validateConfig(input) {
335
337
  return {
336
338
  config: {
337
339
  namespace: DEFAULTS.namespace,
340
+ shotPort: DEFAULTS.shotPort,
338
341
  brand: { label: DEFAULTS.brandLabel },
339
342
  loginField: { ...DEFAULTS.loginField },
340
343
  credentials: [],
@@ -342,6 +345,7 @@ function validateConfig(input) {
342
345
  preamble: null,
343
346
  rtl: DEFAULTS.rtl,
344
347
  beta: DEFAULTS.beta,
348
+ collector: null,
345
349
  visible: DEFAULTS.visible,
346
350
  alwaysVisible: DEFAULTS.alwaysVisible,
347
351
  hotkey: DEFAULTS.hotkey,
@@ -356,6 +360,7 @@ function validateConfig(input) {
356
360
  return {
357
361
  config: {
358
362
  namespace: DEFAULTS.namespace,
363
+ shotPort: DEFAULTS.shotPort,
359
364
  brand: { label: DEFAULTS.brandLabel },
360
365
  loginField: { ...DEFAULTS.loginField },
361
366
  credentials: [],
@@ -363,6 +368,7 @@ function validateConfig(input) {
363
368
  preamble: null,
364
369
  rtl: DEFAULTS.rtl,
365
370
  beta: DEFAULTS.beta,
371
+ collector: null,
366
372
  visible: DEFAULTS.visible,
367
373
  alwaysVisible: DEFAULTS.alwaysVisible,
368
374
  hotkey: DEFAULTS.hotkey,
@@ -374,6 +380,8 @@ function validateConfig(input) {
374
380
  }
375
381
  const raw = input;
376
382
  const namespace = isNonEmptyString(raw["namespace"]) ? raw["namespace"].trim() : DEFAULTS.namespace;
383
+ const rawPort = Number(raw["shotPort"]);
384
+ const shotPort = Number.isInteger(rawPort) && rawPort > 0 && rawPort < 65536 ? rawPort : DEFAULTS.shotPort;
377
385
  if (raw["theme"] !== void 0) {
378
386
  warnings.push(
379
387
  'theme: custom themes were removed in Qapture 0.3.0 \u2014 the widget now ships one fixed, self-contained design. The "theme" key is ignored; remove it from your qa.config to silence this warning.'
@@ -399,6 +407,14 @@ function validateConfig(input) {
399
407
  const preamble = raw["preamble"] !== void 0 ? coercePreamble(raw["preamble"]) : null;
400
408
  const rtl = typeof raw["rtl"] === "boolean" ? raw["rtl"] : DEFAULTS.rtl;
401
409
  const beta = typeof raw["beta"] === "boolean" ? raw["beta"] : DEFAULTS.beta;
410
+ const rawCol = raw["collector"];
411
+ const collector = rawCol && typeof rawCol["url"] === "string" && typeof rawCol["token"] === "string" && typeof rawCol["project"] === "string" ? {
412
+ url: rawCol["url"],
413
+ token: rawCol["token"],
414
+ project: rawCol["project"],
415
+ campaign: typeof rawCol["campaign"] === "string" ? rawCol["campaign"] : void 0,
416
+ tester: typeof rawCol["tester"] === "string" ? rawCol["tester"] : void 0
417
+ } : DEFAULTS.collector;
402
418
  const alwaysVisible = typeof raw["alwaysVisible"] === "boolean" ? raw["alwaysVisible"] : DEFAULTS.alwaysVisible;
403
419
  const hotkey = isNonEmptyString(raw["hotkey"]) ? raw["hotkey"].trim() : DEFAULTS.hotkey;
404
420
  const captureHotkey = isNonEmptyString(raw["captureHotkey"]) ? raw["captureHotkey"].trim() : DEFAULTS.captureHotkey;
@@ -415,6 +431,7 @@ function validateConfig(input) {
415
431
  return {
416
432
  config: {
417
433
  namespace,
434
+ shotPort,
418
435
  brand: { label: brandLabel },
419
436
  loginField,
420
437
  credentials,
@@ -422,6 +439,7 @@ function validateConfig(input) {
422
439
  preamble,
423
440
  rtl,
424
441
  beta,
442
+ collector,
425
443
  visible,
426
444
  alwaysVisible,
427
445
  hotkey,
@@ -1699,6 +1717,98 @@ function createStorage(namespace) {
1699
1717
  return { getItem, setItem, getJSON, setJSON };
1700
1718
  }
1701
1719
 
1720
+ // src/lib/faultLog.ts
1721
+ var LIMIT = 40;
1722
+ var faults = [];
1723
+ function recordFault(where, err) {
1724
+ const what = err instanceof Error ? `${err.name}: ${err.message}` : typeof err === "string" ? err : (() => {
1725
+ try {
1726
+ return JSON.stringify(err);
1727
+ } catch {
1728
+ return String(err);
1729
+ }
1730
+ })();
1731
+ faults.push({ at: Date.now(), where, what: what.slice(0, 500) });
1732
+ if (faults.length > LIMIT) faults.splice(0, faults.length - LIMIT);
1733
+ console.warn(`[QA] ${where}:`, err);
1734
+ }
1735
+ function readFaults() {
1736
+ return [...faults].reverse();
1737
+ }
1738
+ function clearFaults() {
1739
+ faults.length = 0;
1740
+ }
1741
+ function faultsAsText(version) {
1742
+ if (!faults.length) return "No faults recorded.";
1743
+ const head = [
1744
+ `qapture ${version}`,
1745
+ typeof navigator !== "undefined" ? navigator.userAgent : "",
1746
+ typeof location !== "undefined" ? location.href.split("?")[0] : "",
1747
+ ""
1748
+ ].filter(Boolean).join("\n");
1749
+ return head + readFaults().map((f) => `${new Date(f.at).toISOString()} [${f.where}] ${f.what}`).join("\n");
1750
+ }
1751
+
1752
+ // src/lib/collector.ts
1753
+ var TIMEOUT_MS = 8e3;
1754
+ var MAX_SHOT_BYTES = 6 * 1024 * 1024;
1755
+ function blobToDataUrl(blob) {
1756
+ return new Promise((resolve) => {
1757
+ try {
1758
+ const reader = new FileReader();
1759
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
1760
+ reader.onerror = () => resolve(null);
1761
+ reader.readAsDataURL(blob);
1762
+ } catch {
1763
+ resolve(null);
1764
+ }
1765
+ });
1766
+ }
1767
+ async function sendToCollector(note, cfg) {
1768
+ if (typeof fetch === "undefined" || !cfg?.url || !cfg.token || !cfg.project) return false;
1769
+ let shot;
1770
+ if (note.screenshot && note.screenshot.size <= MAX_SHOT_BYTES) {
1771
+ shot = await blobToDataUrl(note.screenshot) ?? void 0;
1772
+ }
1773
+ const controller = new AbortController();
1774
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
1775
+ try {
1776
+ const res = await fetch(`${cfg.url.replace(/\/$/, "")}/notes`, {
1777
+ method: "POST",
1778
+ signal: controller.signal,
1779
+ headers: {
1780
+ "content-type": "application/json",
1781
+ authorization: `Bearer ${cfg.token}`
1782
+ },
1783
+ body: JSON.stringify({
1784
+ project: cfg.project,
1785
+ // A campaign per day is the shape that matches how testing actually
1786
+ // happens, and it means nobody has to name anything.
1787
+ campaign: cfg.campaign || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
1788
+ tester: cfg.tester,
1789
+ id: note.id,
1790
+ route: note.route,
1791
+ description: note.description,
1792
+ wanted: note.wanted,
1793
+ why: note.why,
1794
+ severity: note.severity,
1795
+ origin: note.origin,
1796
+ shot
1797
+ })
1798
+ });
1799
+ if (!res.ok) {
1800
+ recordFault("collector", `server answered ${res.status}`);
1801
+ return false;
1802
+ }
1803
+ return true;
1804
+ } catch (err) {
1805
+ recordFault("collector", err);
1806
+ return false;
1807
+ } finally {
1808
+ clearTimeout(timer);
1809
+ }
1810
+ }
1811
+
1702
1812
  // src/lib/strings.ts
1703
1813
  var STR = {
1704
1814
  en: {
@@ -1827,6 +1937,8 @@ var STR = {
1827
1937
  exact_declined: "Staying on redrawn screenshots",
1828
1938
  exact_turn_on: "Turn on",
1829
1939
  exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1940
+ exact_native_hint: "Screenshots are real photographs, taken by the qapture helper on this machine with the same engine as Cmd+Shift+4. It never asks permission and leaves nothing recording, so it costs no battery. Nothing leaves your device.",
1941
+ exact_native_on: "Real screenshots on \u2014 local helper",
1830
1942
  sync_title: "Save to a folder",
1831
1943
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1832
1944
  sync_choose: "Choose folder",
@@ -2064,6 +2176,8 @@ var STR = {
2064
2176
  exact_off: "\u0627\u0644\u0639\u0648\u062F\u0629 \u0625\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
2065
2177
  exact_declined: "\u0633\u0646\u0628\u0642\u0649 \u0639\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
2066
2178
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
2179
+ exact_native_hint: "\u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0635\u0648\u0631 \u062D\u0642\u064A\u0642\u064A\u0629 \u064A\u0644\u062A\u0642\u0637\u0647\u0627 \u0645\u0633\u0627\u0639\u062F qapture \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632 \u0628\u0627\u0644\u0645\u062D\u0631\u0643 \u0646\u0641\u0633\u0647 \u0627\u0644\u0630\u064A \u064A\u0633\u062A\u062E\u062F\u0645\u0647 Cmd+Shift+4. \u0644\u0627 \u064A\u0637\u0644\u0628 \u0625\u0630\u0646\u064B\u0627 \u0648\u0644\u0627 \u064A\u062A\u0631\u0643 \u0623\u064A \u062A\u0633\u062C\u064A\u0644 \u064A\u0639\u0645\u0644\u060C \u0641\u0644\u0627 \u064A\u0633\u062A\u0647\u0644\u0643 \u0627\u0644\u0628\u0637\u0627\u0631\u064A\u0629. \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
2180
+ exact_native_on: "\u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u062D\u0642\u064A\u0642\u064A\u0629 \u0645\u0641\u0639\u0651\u0644\u0629 \u2014 \u0627\u0644\u0645\u0633\u0627\u0639\u062F \u0627\u0644\u0645\u062D\u0644\u064A",
2067
2181
  exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u2014 \u0627\u0644\u0647\u0648\u0627\u062A\u0641 \u0644\u0627 \u062A\u0633\u062A\u0637\u064A\u0639 \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629",
2068
2182
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
2069
2183
  sync_hint: "\u062A\u064F\u0643\u062A\u0628 \u0643\u0644 \u0645\u0644\u0627\u062D\u0638\u0629 \u0639\u0644\u0649 \u0627\u0644\u0642\u0631\u0635 \u0644\u062D\u0638\u0629 \u062D\u0641\u0638\u0647\u0627 \u2014 \u0644\u0627 \u0634\u064A\u0621 \u064A\u0636\u064A\u0639 \u0625\u0630\u0627 \u062A\u0639\u0637\u0651\u0644 \u0627\u0644\u0645\u062A\u0635\u0641\u062D.",
@@ -2400,13 +2514,132 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2400
2514
  if (sw < 1 || sh < 1) return null;
2401
2515
  return { sx, sy, sw, sh };
2402
2516
  }
2517
+ function environmentSignature() {
2518
+ if (typeof window === "undefined") return "";
2519
+ return [
2520
+ window.innerWidth,
2521
+ window.innerHeight,
2522
+ window.outerWidth,
2523
+ window.outerHeight,
2524
+ window.screenX,
2525
+ window.screenY,
2526
+ Math.round((window.devicePixelRatio || 1) * 100)
2527
+ ].join("x");
2528
+ }
2529
+
2530
+ // src/lib/nativeShot.ts
2531
+ var DEFAULT_SHOT_PORT = 7017;
2532
+ var PROBE_TIMEOUT_MS = 700;
2533
+ var SHOT_TIMEOUT_MS = 9e3;
2534
+ var ABSENT_RECHECK_MS = 3e4;
2535
+ var configuredPort = DEFAULT_SHOT_PORT;
2536
+ function setShotPort(port) {
2537
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return;
2538
+ if (port === configuredPort) return;
2539
+ configuredPort = port;
2540
+ resetNativeShotProbe();
2541
+ }
2542
+ var cachedBase = null;
2543
+ var lastProbeAt = 0;
2544
+ var lastProbeResult = false;
2545
+ function baseUrl(port) {
2546
+ return `http://127.0.0.1:${port}`;
2547
+ }
2548
+ async function fetchWithTimeout(url, init, ms) {
2549
+ if (typeof fetch !== "function") return null;
2550
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
2551
+ const timer = setTimeout(() => ac?.abort(), ms);
2552
+ try {
2553
+ return await fetch(url, { ...init, signal: ac?.signal, cache: "no-store" });
2554
+ } catch {
2555
+ return null;
2556
+ } finally {
2557
+ clearTimeout(timer);
2558
+ }
2559
+ }
2560
+ async function isNativeShotAvailable(port = configuredPort) {
2561
+ if (typeof window === "undefined") return false;
2562
+ const now2 = Date.now();
2563
+ if (lastProbeResult && cachedBase) return true;
2564
+ if (!lastProbeResult && now2 - lastProbeAt < ABSENT_RECHECK_MS) return false;
2565
+ lastProbeAt = now2;
2566
+ const res = await fetchWithTimeout(`${baseUrl(port)}/qapture/health`, { method: "GET" }, PROBE_TIMEOUT_MS);
2567
+ lastProbeResult = !!res && res.ok;
2568
+ cachedBase = lastProbeResult ? baseUrl(port) : null;
2569
+ return lastProbeResult;
2570
+ }
2571
+ function resetNativeShotProbe() {
2572
+ cachedBase = null;
2573
+ lastProbeAt = 0;
2574
+ lastProbeResult = false;
2575
+ }
2576
+ async function shootBrowserWindow(port = configuredPort) {
2577
+ if (typeof window === "undefined") return null;
2578
+ const base = cachedBase ?? baseUrl(port);
2579
+ const body = JSON.stringify({
2580
+ x: window.screenX,
2581
+ y: window.screenY,
2582
+ w: window.outerWidth,
2583
+ h: window.outerHeight
2584
+ });
2585
+ const res = await fetchWithTimeout(
2586
+ `${base}/qapture/shot`,
2587
+ { method: "POST", headers: { "content-type": "application/json" }, body },
2588
+ SHOT_TIMEOUT_MS
2589
+ );
2590
+ if (!res || !res.ok) return null;
2591
+ let payload;
2592
+ try {
2593
+ payload = await res.json();
2594
+ } catch {
2595
+ return null;
2596
+ }
2597
+ if (!payload.png) return null;
2598
+ return decodeToCanvas(payload.png);
2599
+ }
2600
+ function decodeToCanvas(dataUrl) {
2601
+ return new Promise((resolve) => {
2602
+ const img = new Image();
2603
+ img.onload = () => {
2604
+ const c = document.createElement("canvas");
2605
+ c.width = img.naturalWidth;
2606
+ c.height = img.naturalHeight;
2607
+ const ctx = c.getContext("2d", { willReadFrequently: true });
2608
+ if (!ctx) {
2609
+ resolve(null);
2610
+ return;
2611
+ }
2612
+ ctx.drawImage(img, 0, 0);
2613
+ resolve(c);
2614
+ };
2615
+ img.onerror = () => resolve(null);
2616
+ img.src = dataUrl;
2617
+ });
2618
+ }
2619
+ var cachedMapping = null;
2620
+ var cachedFor = "";
2621
+ function getCachedMapping() {
2622
+ if (!cachedMapping) return null;
2623
+ return environmentSignature() === cachedFor ? cachedMapping : null;
2624
+ }
2625
+ function cacheMapping(m) {
2626
+ cachedMapping = m;
2627
+ cachedFor = m ? environmentSignature() : "";
2628
+ }
2403
2629
 
2404
2630
  // src/lib/screenCapture.ts
2405
2631
  var ASPECT_TOLERANCE = 0.08;
2406
2632
  var CALIBRATION_SETTLE_MS = 220;
2407
2633
  var FRESH_FRAME_TIMEOUT_MS = 500;
2408
2634
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2635
+ var nativeReady = false;
2636
+ async function refreshNativeAvailability(port) {
2637
+ if (typeof port === "number") setShotPort(port);
2638
+ nativeReady = await isNativeShotAvailable();
2639
+ return nativeReady;
2640
+ }
2409
2641
  function isExactCaptureSupported() {
2642
+ if (nativeReady) return true;
2410
2643
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2411
2644
  const md = navigator.mediaDevices;
2412
2645
  return !!md && typeof md.getDisplayMedia === "function";
@@ -2414,13 +2647,15 @@ function isExactCaptureSupported() {
2414
2647
  var armed = false;
2415
2648
  var declined = false;
2416
2649
  var frozen = null;
2417
- var lastMode = null;
2418
2650
  function armExactCapture() {
2419
2651
  if (!isExactCaptureSupported()) return false;
2420
2652
  armed = true;
2421
2653
  declined = false;
2422
2654
  return true;
2423
2655
  }
2656
+ function exactCaptureIsFree() {
2657
+ return nativeReady;
2658
+ }
2424
2659
  function disarmExactCapture() {
2425
2660
  armed = false;
2426
2661
  releaseFrozenFrame();
@@ -2429,6 +2664,7 @@ function resetExactCaptureDecline() {
2429
2664
  declined = false;
2430
2665
  }
2431
2666
  function getExactCaptureStatus() {
2667
+ if (nativeReady) return "native";
2432
2668
  if (!isExactCaptureSupported()) return "unsupported";
2433
2669
  if (armed) return "live";
2434
2670
  if (declined) return "declined";
@@ -2453,7 +2689,7 @@ function stillIsCurrent() {
2453
2689
  return true;
2454
2690
  }
2455
2691
  async function freezeOrReuse() {
2456
- if (stillIsCurrent()) return frozen;
2692
+ if (!nativeReady && stillIsCurrent()) return frozen;
2457
2693
  return freezeViewport();
2458
2694
  }
2459
2695
  function releaseFrozenFrame() {
@@ -2553,7 +2789,7 @@ async function grabFrameCanvas(video, grabber) {
2553
2789
  bitmap?.close?.();
2554
2790
  return c;
2555
2791
  }
2556
- async function calibrate(video, grabber) {
2792
+ async function calibrate(grabFrame) {
2557
2793
  const vw = window.innerWidth;
2558
2794
  const vh = window.innerHeight;
2559
2795
  if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
@@ -2570,7 +2806,7 @@ async function calibrate(video, grabber) {
2570
2806
  document.body.appendChild(card);
2571
2807
  try {
2572
2808
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2573
- const frame = await grabFrameCanvas(video, grabber);
2809
+ const frame = await grabFrame();
2574
2810
  if (!frame) return null;
2575
2811
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2576
2812
  if (!ctx) return null;
@@ -2592,8 +2828,69 @@ async function calibrate(video, grabber) {
2592
2828
  }
2593
2829
  }
2594
2830
  async function freezeViewport() {
2595
- if (!isExactCaptureSupported()) return null;
2596
2831
  releaseFrozenFrame();
2832
+ const native = await freezeViaNativeHelper();
2833
+ if (native) return native;
2834
+ if (!isExactCaptureSupported()) return null;
2835
+ return freezeViaDisplayMedia();
2836
+ }
2837
+ function adoptFrame(raw, mapping, mode, vw, vh) {
2838
+ let page;
2839
+ if (mapping) {
2840
+ const box = mapRectToFrame(
2841
+ { left: 0, top: 0, width: vw, height: vh },
2842
+ mapping,
2843
+ raw.width,
2844
+ raw.height
2845
+ );
2846
+ if (!box) return null;
2847
+ page = document.createElement("canvas");
2848
+ page.width = box.sw;
2849
+ page.height = box.sh;
2850
+ const ctx = page.getContext("2d");
2851
+ if (!ctx) return null;
2852
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2853
+ raw.width = 0;
2854
+ raw.height = 0;
2855
+ } else {
2856
+ page = raw;
2857
+ }
2858
+ frozen = {
2859
+ canvas: page,
2860
+ mode,
2861
+ viewportWidth: vw,
2862
+ viewportHeight: vh,
2863
+ takenAt: Date.now()
2864
+ };
2865
+ frozenAtScrollX = window.scrollX;
2866
+ frozenAtScrollY = window.scrollY;
2867
+ frozenAtPath = window.location.pathname + window.location.search;
2868
+ return frozen;
2869
+ }
2870
+ async function freezeViaNativeHelper() {
2871
+ if (typeof window === "undefined") return null;
2872
+ nativeReady = await isNativeShotAvailable();
2873
+ if (!nativeReady) return null;
2874
+ const vw = window.innerWidth;
2875
+ const vh = window.innerHeight;
2876
+ let mapping = getCachedMapping();
2877
+ if (!mapping) {
2878
+ mapping = await calibrate(() => shootBrowserWindow());
2879
+ if (!mapping) {
2880
+ cacheMapping(null);
2881
+ return null;
2882
+ }
2883
+ cacheMapping(mapping);
2884
+ }
2885
+ const raw = await withOverlayHidden(() => shootBrowserWindow());
2886
+ if (!raw) return null;
2887
+ const adopted = adoptFrame(raw, mapping, "native", vw, vh);
2888
+ if (!adopted) {
2889
+ cacheMapping(null);
2890
+ }
2891
+ return adopted;
2892
+ }
2893
+ async function freezeViaDisplayMedia() {
2597
2894
  let stream = null;
2598
2895
  let el = null;
2599
2896
  const release = () => {
@@ -2666,45 +2963,14 @@ async function freezeViewport() {
2666
2963
  const vw = window.innerWidth;
2667
2964
  const vh = window.innerHeight;
2668
2965
  const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2669
- const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2966
+ const mapping = mode === "surface" ? await calibrate(() => grabFrameCanvas(video, grabber)) : null;
2670
2967
  if (mode === "surface" && !mapping) {
2671
2968
  declined = true;
2672
2969
  return null;
2673
2970
  }
2674
2971
  const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2675
2972
  if (!raw) return null;
2676
- let page;
2677
- if (mode === "surface" && mapping) {
2678
- const box = mapRectToFrame(
2679
- { left: 0, top: 0, width: vw, height: vh },
2680
- mapping,
2681
- raw.width,
2682
- raw.height
2683
- );
2684
- if (!box) return null;
2685
- page = document.createElement("canvas");
2686
- page.width = box.sw;
2687
- page.height = box.sh;
2688
- const ctx = page.getContext("2d");
2689
- if (!ctx) return null;
2690
- ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2691
- raw.width = 0;
2692
- raw.height = 0;
2693
- } else {
2694
- page = raw;
2695
- }
2696
- frozen = {
2697
- canvas: page,
2698
- mode,
2699
- viewportWidth: vw,
2700
- viewportHeight: vh,
2701
- takenAt: Date.now()
2702
- };
2703
- frozenAtScrollX = window.scrollX;
2704
- frozenAtScrollY = window.scrollY;
2705
- frozenAtPath = window.location.pathname + window.location.search;
2706
- lastMode = mode;
2707
- return frozen;
2973
+ return adoptFrame(raw, mode === "surface" ? mapping : null, mode, vw, vh);
2708
2974
  } catch {
2709
2975
  declined = true;
2710
2976
  return null;
@@ -2874,38 +3140,6 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2874
3140
  return touched;
2875
3141
  }
2876
3142
 
2877
- // src/lib/faultLog.ts
2878
- var LIMIT = 40;
2879
- var faults = [];
2880
- function recordFault(where, err) {
2881
- const what = err instanceof Error ? `${err.name}: ${err.message}` : typeof err === "string" ? err : (() => {
2882
- try {
2883
- return JSON.stringify(err);
2884
- } catch {
2885
- return String(err);
2886
- }
2887
- })();
2888
- faults.push({ at: Date.now(), where, what: what.slice(0, 500) });
2889
- if (faults.length > LIMIT) faults.splice(0, faults.length - LIMIT);
2890
- console.warn(`[QA] ${where}:`, err);
2891
- }
2892
- function readFaults() {
2893
- return [...faults].reverse();
2894
- }
2895
- function clearFaults() {
2896
- faults.length = 0;
2897
- }
2898
- function faultsAsText(version) {
2899
- if (!faults.length) return "No faults recorded.";
2900
- const head = [
2901
- `qapture ${version}`,
2902
- typeof navigator !== "undefined" ? navigator.userAgent : "",
2903
- typeof location !== "undefined" ? location.href.split("?")[0] : "",
2904
- ""
2905
- ].filter(Boolean).join("\n");
2906
- return head + readFaults().map((f) => `${new Date(f.at).toISOString()} [${f.where}] ${f.what}`).join("\n");
2907
- }
2908
-
2909
3143
  // src/lib/capture.ts
2910
3144
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2911
3145
  var CHUNK_TIMEOUT_MS = 8e3;
@@ -3348,7 +3582,7 @@ function noteCheckLine(note, index) {
3348
3582
  const where = oneLine(note.route) || "/";
3349
3583
  const wanted = oneLine(note.wanted);
3350
3584
  const seen = oneLine(note.description) || "(not described)";
3351
- const claim = wanted || `${seen} \u2014 _no expectation was given; ask before assuming one_`;
3585
+ const claim = wanted || seen;
3352
3586
  const trimmed = claim.length > 180 ? `${claim.slice(0, 177)}...` : claim;
3353
3587
  return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3354
3588
  }
@@ -3398,12 +3632,12 @@ function noteToMarkdown(note, opts) {
3398
3632
  lines.push("### Observed");
3399
3633
  lines.push("");
3400
3634
  lines.push(oneLine(note.description) ? note.description.trim() : "_(not described)_");
3401
- lines.push("");
3402
- lines.push("### Expected");
3403
- lines.push("");
3404
- lines.push(
3405
- note.wanted && oneLine(note.wanted) ? note.wanted.trim() : "_(the tester did not say what they expected instead -- ask rather than assume)_"
3406
- );
3635
+ if (note.wanted && oneLine(note.wanted)) {
3636
+ lines.push("");
3637
+ lines.push("### Expected");
3638
+ lines.push("");
3639
+ lines.push(note.wanted.trim());
3640
+ }
3407
3641
  if (note.why && oneLine(note.why)) {
3408
3642
  lines.push("");
3409
3643
  lines.push("### Why it matters");
@@ -3557,11 +3791,24 @@ function reproSpec(note, index) {
3557
3791
  }
3558
3792
 
3559
3793
  // src/lib/exportZip.ts
3560
- function safeName(name, stamp) {
3561
- const fallback = `qa-notes-${stamp.slice(0, 10)}`;
3794
+ function autoName(project, stamp) {
3795
+ const date = stamp.slice(0, 10);
3796
+ const time = stamp.slice(11, 16).replace(":", "");
3797
+ const slug = (project ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3798
+ const when = time ? `${date}-${time}` : date;
3799
+ return slug ? `${slug}-qa-${when}` : `qa-notes-${when}`;
3800
+ }
3801
+ function exportProjectName(config) {
3802
+ const name = config?.preamble?.projectName;
3803
+ return typeof name === "string" && name.trim() ? name.trim() : void 0;
3804
+ }
3805
+ function safeName(name, stamp, project) {
3562
3806
  let base = (name ?? "").trim().replace(/\.zip$/i, "");
3563
3807
  base = base.replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80).trim();
3564
- return `${base || fallback}.zip`;
3808
+ return `${base || autoName(project, stamp)}.zip`;
3809
+ }
3810
+ function suggestedExportName(project, stamp) {
3811
+ return autoName(project, stamp);
3565
3812
  }
3566
3813
  function toStrings(val) {
3567
3814
  if (val == null) return [];
@@ -3693,7 +3940,8 @@ Hand \`verify.md\` back with the work.
3693
3940
 
3694
3941
  ### What is in this archive
3695
3942
 
3696
- - \`notes.md\` \u2014 the points themselves. Each one carries **Observed** and **Expected** under those exact headings. They are separate on purpose: where a report leaves the expectation out, agents do not stop and ask, they pick a reading and commit to it. Where you see Expected marked as not given, **ask rather than assume**.
3943
+ - \`notes.md\` \u2014 the points themselves, under an **Observed** heading. Most carry only that: the tester writes one sentence about what is wrong, which is the right amount to ask of somebody who is not an engineer. An **Expected** heading appears where somebody stated one.
3944
+ Where a point is ambiguous, **ask \u2014 do not pick a reading and commit to it.** That failure mode is specific to you: a person would stop and check, and an agent tends to fill the gap in and carry on. One question costs a message. A confident fix to the wrong problem costs the round.
3697
3945
  - \`verify.md\` \u2014 the checklist, one unticked box per point.
3698
3946
  - \`screenshots/\` \u2014 one per point. A point marked with a screenshot caveat was re-drawn rather than photographed, so canvases, charts and maps may be blank in it; trust the words over the picture there.
3699
3947
  - \`context/\` \u2014 console, network, environment and element forensics, one file per point. Deliberately **not** in \`notes.md\`: a longer report measurably lowers the chance of the right thing getting fixed, because the two sentences that matter get buried. Open these only when something is genuinely unresolved.
@@ -3938,14 +4186,14 @@ async function buildAndDownloadZip(notes, stamp, filename, config, guideChecked,
3938
4186
  const url = URL.createObjectURL(blob);
3939
4187
  const a = document.createElement("a");
3940
4188
  a.href = url;
3941
- a.download = safeName(filename, stamp);
4189
+ a.download = safeName(filename, stamp, exportProjectName(config));
3942
4190
  document.body.appendChild(a);
3943
4191
  a.click();
3944
4192
  a.remove();
3945
4193
  setTimeout(() => URL.revokeObjectURL(url), 2e3);
3946
4194
  }
3947
- function exportFileName(filename, stamp) {
3948
- return safeName(filename, stamp);
4195
+ function exportFileName(filename, stamp, project) {
4196
+ return safeName(filename, stamp, project);
3949
4197
  }
3950
4198
 
3951
4199
  // src/lib/shareZip.ts
@@ -4518,18 +4766,6 @@ async function requestPersistentStorage() {
4518
4766
  return false;
4519
4767
  }
4520
4768
  }
4521
- function formatBytes(bytes) {
4522
- if (!Number.isFinite(bytes) || bytes <= 0) return "0 KB";
4523
- const units = ["B", "KB", "MB", "GB", "TB"];
4524
- let value = bytes;
4525
- let i = 0;
4526
- while (value >= 1024 && i < units.length - 1) {
4527
- value /= 1024;
4528
- i++;
4529
- }
4530
- const decimals = value < 10 && i > 1 ? 1 : 0;
4531
- return `${value.toFixed(decimals)} ${units[i]}`;
4532
- }
4533
4769
  function estimateOwnBytes(notes) {
4534
4770
  let total = 0;
4535
4771
  for (const n of notes) {
@@ -4655,7 +4891,23 @@ function QaProvider({
4655
4891
  if (exactShotsWanted(storage)) armExactCapture();
4656
4892
  return getExactCaptureStatus();
4657
4893
  });
4658
- const exactSupported = isExactCaptureSupported();
4894
+ const [exactSupported, setExactSupported] = useState(() => isExactCaptureSupported());
4895
+ useEffect(() => {
4896
+ let alive = true;
4897
+ const look = () => {
4898
+ void refreshNativeAvailability(config.shotPort).then(() => {
4899
+ if (!alive) return;
4900
+ setExactSupported(isExactCaptureSupported());
4901
+ setExactStatus(getExactCaptureStatus());
4902
+ });
4903
+ };
4904
+ look();
4905
+ window.addEventListener("focus", look);
4906
+ return () => {
4907
+ alive = false;
4908
+ window.removeEventListener("focus", look);
4909
+ };
4910
+ }, [config.shotPort]);
4659
4911
  const [frozenAt, setFrozenAt] = useState(null);
4660
4912
  const [syncState, setSyncState] = useState(() => getFsSyncState());
4661
4913
  const [syncTick, setSyncTick] = useState(0);
@@ -5031,8 +5283,11 @@ function QaProvider({
5031
5283
  });
5032
5284
  }
5033
5285
  await syncNoteThrough(note);
5286
+ if (config.collector) {
5287
+ void sendToCollector(note, config.collector);
5288
+ }
5034
5289
  },
5035
- [idb, config.journey, config.captureContext, testAlong, testAlongSteps, notify, t, syncNoteThrough, applyNotes]
5290
+ [idb, config.journey, config.captureContext, config.collector, testAlong, testAlongSteps, notify, t, syncNoteThrough, applyNotes]
5036
5291
  );
5037
5292
  const updateNote = useCallback(
5038
5293
  async (id, patch) => {
@@ -5178,7 +5433,7 @@ function QaProvider({
5178
5433
  setCapturePrefill(prefill ?? "");
5179
5434
  setCaptureActive(true);
5180
5435
  if (!stillIsCurrent()) setFrozenAt(null);
5181
- if (exactShotsWanted(storage) && isExactCaptureSupported()) {
5436
+ if (exactCaptureIsFree() || exactShotsWanted(storage) && isExactCaptureSupported()) {
5182
5437
  resetExactCaptureDecline();
5183
5438
  void freezeOrReuse().then((frame) => {
5184
5439
  setFrozenAt(frame?.takenAt ?? null);
@@ -5496,7 +5751,7 @@ function QaProvider({
5496
5751
  const shareExport = useCallback(async (filename) => {
5497
5752
  if (!notes.length) return { status: "unsupported" };
5498
5753
  const stamp = nowIso();
5499
- const name = exportFileName(filename, stamp);
5754
+ const name = exportFileName(filename, stamp, exportProjectName(config));
5500
5755
  setIsExporting(true);
5501
5756
  try {
5502
5757
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
@@ -5870,6 +6125,7 @@ function QaProvider({
5870
6125
  setSimpleMode,
5871
6126
  compactCapture,
5872
6127
  developerMode,
6128
+ projectName: exportProjectName(config),
5873
6129
  setDeveloperMode,
5874
6130
  setCompactCapture,
5875
6131
  exportZip: exportZipFn
@@ -8143,7 +8399,7 @@ function upgradeHint(latest) {
8143
8399
  }
8144
8400
 
8145
8401
  // src/version.ts
8146
- var QA_VERSION = "0.9.0" ;
8402
+ var QA_VERSION = "0.10.0" ;
8147
8403
  function Section({
8148
8404
  icon,
8149
8405
  title,
@@ -8249,8 +8505,8 @@ function SettingsSheet({ onClose }) {
8249
8505
  const syncing = sync.state === "syncing";
8250
8506
  const viaZip = sync.engine === "download";
8251
8507
  const quotaKnown = storageHealth.supported && storageHealth.quotaBytes > 0;
8252
- const usedPct = quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
8253
- const meterColor = storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
8508
+ quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
8509
+ storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
8254
8510
  return /* @__PURE__ */ jsxs(
8255
8511
  "div",
8256
8512
  {
@@ -8379,81 +8635,13 @@ function SettingsSheet({ onClose }) {
8379
8635
  )
8380
8636
  ] }),
8381
8637
  /* @__PURE__ */ jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8382
- /* @__PURE__ */ jsxs(Section, { icon: "HardDrive", title: t("storage_title"), children: [
8383
- /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("storage_explain") }),
8384
- quotaKnown && /* @__PURE__ */ jsxs(Fragment, { children: [
8385
- /* @__PURE__ */ jsx(
8386
- "div",
8387
- {
8388
- className: "qa-w-full qa-rounded-full qa-overflow-hidden qa-bg-3",
8389
- role: "img",
8390
- "aria-label": t("storage_used", {
8391
- used: formatBytes(storageHealth.usageBytes),
8392
- quota: formatBytes(storageHealth.quotaBytes)
8393
- }),
8394
- style: { height: 6 },
8395
- children: /* @__PURE__ */ jsx("div", { style: { width: `${usedPct}%`, height: "100%", background: meterColor } })
8396
- }
8397
- ),
8398
- /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: [
8399
- t("storage_used", {
8400
- used: formatBytes(storageHealth.usageBytes),
8401
- quota: formatBytes(storageHealth.quotaBytes)
8402
- }),
8403
- " \xB7 ",
8404
- /* @__PURE__ */ jsxs("span", { className: "qa-text-lo", children: [
8405
- "Qapture ",
8406
- formatBytes(storageHealth.ownBytes)
8407
- ] })
8408
- ] })
8409
- ] }),
8410
- /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-items-start qa-gap-2 qa-text-xs qa-text-hi", style: { cursor: "pointer" }, children: [
8411
- /* @__PURE__ */ jsx(
8412
- "input",
8413
- {
8414
- type: "checkbox",
8415
- checked: autoBackup,
8416
- onChange: (e) => setAutoBackup(e.target.checked),
8417
- style: { marginTop: 2 }
8418
- }
8419
- ),
8420
- /* @__PURE__ */ jsxs("span", { children: [
8421
- t("autosave_label"),
8422
- /* @__PURE__ */ jsx("span", { className: "qa-block qa-text-10 qa-text-lo qa-leading-relaxed", children: t("autosave_hint", { n: autoBackupEvery }) })
8423
- ] })
8424
- ] }),
8425
- /* @__PURE__ */ jsxs("div", { className: "qa-flex qa-flex-wrap qa-gap-2", children: [
8426
- storageHealth.persisted ? /* @__PURE__ */ jsxs("span", { className: "qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-bg-success-tint qa-text-success qa-px-2 qa-py-0.5 qa-text-10", children: [
8427
- /* @__PURE__ */ jsx(Icon, { name: "CheckCircle2", size: 11 }),
8428
- t("persist_on")
8429
- ] }) : /* @__PURE__ */ jsx(
8430
- "button",
8431
- {
8432
- type: "button",
8433
- disabled: busy,
8434
- onClick: () => void run2(requestPersistentStorage2),
8435
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
8436
- style: { background: "transparent", cursor: "pointer" },
8437
- children: t("persist_keep")
8438
- }
8439
- ),
8440
- notes.some((n) => n.screenshot) && /* @__PURE__ */ jsx(
8441
- "button",
8442
- {
8443
- type: "button",
8444
- disabled: busy,
8445
- onClick: () => void run2(dropAllScreenshots),
8446
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
8447
- style: { background: "transparent", cursor: "pointer" },
8448
- children: t("drop_shots")
8449
- }
8450
- )
8451
- ] })
8452
- ] }),
8453
8638
  /* @__PURE__ */ jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8454
8639
  /* @__PURE__ */ jsxs(Section, { icon: "Camera", title: t("exact_label"), children: [
8455
- /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("exact_hint") }),
8456
- !exactShots.supported ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxs(
8640
+ /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: exactShots.status === "native" ? t("exact_native_hint") : t("exact_hint") }),
8641
+ exactShots.status === "native" ? /* @__PURE__ */ jsxs("p", { className: "qa-m-0 qa-inline-flex qa-items-center qa-gap-1.5 qa-text-xs qa-font-semibold qa-text-success", children: [
8642
+ /* @__PURE__ */ jsx(Icon, { name: "CheckCircle2", size: 13 }),
8643
+ t("exact_native_on")
8644
+ ] }) : !exactShots.supported ? /* @__PURE__ */ jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxs(
8457
8645
  "button",
8458
8646
  {
8459
8647
  type: "button",
@@ -8827,8 +9015,8 @@ var TABS = ALL_TABS.filter((tab) => tab.key !== "guide" || GUIDE_TAB_ENABLED);
8827
9015
  function visibleTab(tab) {
8828
9016
  return tab === "guide" && !GUIDE_TAB_ENABLED ? "notes" : tab;
8829
9017
  }
8830
- function todayName() {
8831
- return `qa-notes-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
9018
+ function suggestName(project) {
9019
+ return suggestedExportName(project, (/* @__PURE__ */ new Date()).toISOString());
8832
9020
  }
8833
9021
  function panelReducer(state2, action) {
8834
9022
  switch (action.type) {
@@ -8890,7 +9078,8 @@ function QaPanel() {
8890
9078
  panelSide,
8891
9079
  setPanelSide,
8892
9080
  panelCollapsed,
8893
- setPanelCollapsed
9081
+ setPanelCollapsed,
9082
+ projectName
8894
9083
  } = useQa();
8895
9084
  const activeTab = visibleTab(storedTab);
8896
9085
  const [confirmClear, setConfirmClear] = useState(false);
@@ -9009,7 +9198,7 @@ function QaPanel() {
9009
9198
  const appliedKeyboardLift = keyboardLiftActive ? keyboardLift : 0;
9010
9199
  if (phase === "hidden") return null;
9011
9200
  const openNaming = () => {
9012
- setFilename(todayName());
9201
+ setFilename(suggestName(projectName));
9013
9202
  setNaming(true);
9014
9203
  };
9015
9204
  const doExport = () => {
@@ -10014,98 +10203,6 @@ function findDuplicate(text, selector, existing) {
10014
10203
  }
10015
10204
  return null;
10016
10205
  }
10017
- function recogniser() {
10018
- if (typeof window === "undefined") return null;
10019
- const w = window;
10020
- return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
10021
- }
10022
- function VoiceButton({
10023
- onText,
10024
- onInterim
10025
- }) {
10026
- const { lang, t } = useQa();
10027
- const [listening, setListening] = useState(false);
10028
- const [failed, setFailed] = useState(null);
10029
- const ref = useRef(null);
10030
- useEffect(() => () => {
10031
- ref.current?.abort();
10032
- ref.current = null;
10033
- }, []);
10034
- const Ctor = recogniser();
10035
- if (!Ctor) return null;
10036
- const stop = () => {
10037
- ref.current?.stop();
10038
- ref.current = null;
10039
- setListening(false);
10040
- onInterim?.("");
10041
- };
10042
- const start = () => {
10043
- if (listening) {
10044
- stop();
10045
- return;
10046
- }
10047
- setFailed(null);
10048
- let rec;
10049
- try {
10050
- rec = new Ctor();
10051
- } catch {
10052
- setFailed(t("voice_failed"));
10053
- return;
10054
- }
10055
- rec.lang = lang === "ar" ? "ar-IQ" : "en-US";
10056
- rec.continuous = true;
10057
- rec.interimResults = true;
10058
- rec.onresult = (e) => {
10059
- let done = "";
10060
- let partial = "";
10061
- for (let i = e.resultIndex; i < e.results.length; i++) {
10062
- const r = e.results[i];
10063
- const text = r[0]?.transcript ?? "";
10064
- if (r.isFinal) done += text;
10065
- else partial += text;
10066
- }
10067
- if (done.trim()) onText(done.trim());
10068
- onInterim?.(partial);
10069
- };
10070
- rec.onerror = (e) => {
10071
- if (e.error && e.error !== "no-speech" && e.error !== "aborted") {
10072
- setFailed(e.error === "not-allowed" ? t("voice_denied") : t("voice_failed"));
10073
- }
10074
- setListening(false);
10075
- onInterim?.("");
10076
- };
10077
- rec.onend = () => {
10078
- setListening(false);
10079
- onInterim?.("");
10080
- };
10081
- try {
10082
- rec.start();
10083
- ref.current = rec;
10084
- setListening(true);
10085
- } catch {
10086
- setFailed(t("voice_failed"));
10087
- }
10088
- };
10089
- return /* @__PURE__ */ jsxs("span", { className: "qa-inline-flex qa-items-center qa-gap-1.5", children: [
10090
- /* @__PURE__ */ jsxs(
10091
- "button",
10092
- {
10093
- type: "button",
10094
- onClick: start,
10095
- "aria-pressed": listening,
10096
- title: listening ? t("voice_stop") : t("voice_start"),
10097
- "data-qa-voice": listening ? "listening" : "idle",
10098
- className: `qa-tap qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-11 qa-focus-ring ${listening ? "qa-bg-danger-tint qa-text-danger" : "qa-bg-2 qa-text-mid"}`,
10099
- style: { cursor: "pointer" },
10100
- children: [
10101
- /* @__PURE__ */ jsx(Icon, { name: listening ? "Square" : "Mic", size: 12 }),
10102
- listening ? t("voice_stop") : t("voice_start")
10103
- ]
10104
- }
10105
- ),
10106
- failed && /* @__PURE__ */ jsx("span", { className: "qa-text-10 qa-text-danger", children: failed })
10107
- ] });
10108
- }
10109
10206
  var DRAG_THRESHOLD2 = 6;
10110
10207
  var TOUCH_DRAG_THRESHOLD = 12;
10111
10208
  var MIN_REGION_SIZE = 8;
@@ -10177,11 +10274,7 @@ function CaptureMode() {
10177
10274
  const [shot, setShot] = useState(null);
10178
10275
  const stillRef = useRef(null);
10179
10276
  const [notesFromThisShot, setNotesFromThisShot] = useState(0);
10180
- const [wanted, setWanted] = useState("");
10181
- const [why, setWhy] = useState("");
10182
- const [fixHint, setFixHint] = useState("");
10183
10277
  const [origin, setOrigin] = useState(void 0);
10184
- const [spoken, setSpoken] = useState("");
10185
10278
  const [shotEngine, setShotEngine] = useState(null);
10186
10279
  const [shotUrl, setShotUrl] = useState(null);
10187
10280
  const [capturing, setCapturing] = useState(false);
@@ -10501,9 +10594,6 @@ function CaptureMode() {
10501
10594
  setShot(null);
10502
10595
  setShotEngine(null);
10503
10596
  setDescription("");
10504
- setWanted("");
10505
- setWhy("");
10506
- setFixHint("");
10507
10597
  setSeverity("bug");
10508
10598
  setTargetForensics(void 0);
10509
10599
  setOrigin(void 0);
@@ -10526,9 +10616,6 @@ function CaptureMode() {
10526
10616
  };
10527
10617
  await addNote({
10528
10618
  description,
10529
- wanted,
10530
- why,
10531
- fixHint,
10532
10619
  screenshot: shot ?? void 0,
10533
10620
  shotEngine: shotEngine ?? void 0,
10534
10621
  target,
@@ -11164,7 +11251,7 @@ function CaptureMode() {
11164
11251
  }
11165
11252
  ),
11166
11253
  developerMode && /* @__PURE__ */ jsx(LocationReveal, { target: selection }),
11167
- developerMode && /* @__PURE__ */ jsxs(
11254
+ /* @__PURE__ */ jsxs(
11168
11255
  "div",
11169
11256
  {
11170
11257
  role: "group",
@@ -11201,87 +11288,24 @@ function CaptureMode() {
11201
11288
  twin.description.length > 70 ? "\u2026" : "",
11202
11289
  "\u201D"
11203
11290
  ] }),
11204
- /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11205
- /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11206
- /* @__PURE__ */ jsx("span", { className: "qa-text-11 qa-font-semibold qa-text-hi", children: t("q_observed") }),
11207
- /* @__PURE__ */ jsx(
11208
- VoiceButton,
11209
- {
11210
- onText: (text) => setDescription((d) => d ? `${d} ${text}` : text),
11211
- onInterim: setSpoken
11291
+ /* @__PURE__ */ jsx(
11292
+ "textarea",
11293
+ {
11294
+ ref: taRef,
11295
+ value: description,
11296
+ onChange: (e) => setDescription(e.target.value),
11297
+ onKeyDown: (e) => {
11298
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11299
+ if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
11300
+ e.preventDefault();
11301
+ setSeverity(SEVERITIES2[Number(e.key) - 1]);
11212
11302
  }
11213
- )
11214
- ] }),
11215
- /* @__PURE__ */ jsx(
11216
- "textarea",
11217
- {
11218
- ref: taRef,
11219
- value: description + (spoken ? ` ${spoken}` : ""),
11220
- onChange: (e) => setDescription(e.target.value),
11221
- onKeyDown: (e) => {
11222
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11223
- if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
11224
- e.preventDefault();
11225
- setSeverity(SEVERITIES2[Number(e.key) - 1]);
11226
- }
11227
- },
11228
- rows: 2,
11229
- placeholder: t("q_observed_hint"),
11230
- className: "qa-w-full qa-resize-y qa-rounded-lg qa-border qa-border-subtle qa-bg-0 qa-text-hi qa-px-2 qa-py-1.5 qa-text-sm qa-focus-ring"
11231
- }
11232
- )
11233
- ] }),
11234
- /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11235
- /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11236
- /* @__PURE__ */ jsx("span", { className: "qa-text-11 qa-font-semibold qa-text-hi", children: t("q_wanted") }),
11237
- /* @__PURE__ */ jsx(VoiceButton, { onText: (x) => setWanted((d) => d ? `${d} ${x}` : x) })
11238
- ] }),
11239
- /* @__PURE__ */ jsx(
11240
- "textarea",
11241
- {
11242
- value: wanted,
11243
- onChange: (e) => setWanted(e.target.value),
11244
- onKeyDown: (e) => {
11245
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11246
- },
11247
- rows: 2,
11248
- placeholder: t("q_wanted_hint"),
11249
- className: "qa-w-full qa-resize-y qa-rounded-lg qa-border qa-border-subtle qa-bg-0 qa-text-hi qa-px-2 qa-py-1.5 qa-text-sm qa-focus-ring"
11250
- }
11251
- )
11252
- ] }),
11253
- /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11254
- /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11255
- /* @__PURE__ */ jsx("span", { className: "qa-text-11 qa-text-mid", children: t("q_why") }),
11256
- /* @__PURE__ */ jsx(VoiceButton, { onText: (x) => setWhy((d) => d ? `${d} ${x}` : x) })
11257
- ] }),
11258
- /* @__PURE__ */ jsx(
11259
- "textarea",
11260
- {
11261
- value: why,
11262
- onChange: (e) => setWhy(e.target.value),
11263
- onKeyDown: (e) => {
11264
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11265
- },
11266
- rows: 1,
11267
- placeholder: t("q_why_hint"),
11268
- className: "qa-w-full qa-resize-y qa-rounded-lg qa-border qa-border-subtle qa-bg-0 qa-text-hi qa-px-2 qa-py-1.5 qa-text-sm qa-focus-ring"
11269
- }
11270
- )
11271
- ] }),
11272
- developerMode && /* @__PURE__ */ jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11273
- /* @__PURE__ */ jsx("span", { className: "qa-text-11 qa-text-mid", children: t("q_fix") }),
11274
- /* @__PURE__ */ jsx(
11275
- "textarea",
11276
- {
11277
- value: fixHint,
11278
- onChange: (e) => setFixHint(e.target.value),
11279
- rows: 1,
11280
- placeholder: t("q_fix_hint"),
11281
- className: "qa-w-full qa-resize-y qa-rounded-lg qa-border qa-border-subtle qa-bg-0 qa-text-hi qa-px-2 qa-py-1.5 qa-text-sm qa-focus-ring"
11282
- }
11283
- )
11284
- ] }),
11303
+ },
11304
+ rows: 3,
11305
+ placeholder: t("annotate_placeholder"),
11306
+ className: "qa-w-full qa-resize-y qa-rounded-lg qa-border qa-border-subtle qa-bg-0 qa-text-hi qa-px-2 qa-py-1.5 qa-text-sm qa-focus-ring"
11307
+ }
11308
+ ),
11285
11309
  developerMode && origin && (origin.component || origin.file) && /* @__PURE__ */ jsxs("p", { className: "qa-text-10 qa-text-mid", "data-qa-origin": "true", children: [
11286
11310
  origin.component ?? "\u2014",
11287
11311
  origin.file ? ` \xB7 ${origin.file}${origin.line ? `:${origin.line}` : ""}` : ""
@@ -11327,7 +11351,8 @@ function CaptureMode() {
11327
11351
  ] }),
11328
11352
  /* @__PURE__ */ jsxs("p", { className: "qa-text-center qa-text-10 qa-text-slate-400", children: [
11329
11353
  t("save_hint"),
11330
- developerMode ? ` \xB7 ${t("severity_keys")}` : ""
11354
+ " \xB7 ",
11355
+ t("severity_keys")
11331
11356
  ] })
11332
11357
  ] })
11333
11358
  ]
@@ -11533,5 +11558,5 @@ function Qapture({ config }) {
11533
11558
  }
11534
11559
 
11535
11560
  export { Qapture, deleteQaDatabase, initQaStudio };
11536
- //# sourceMappingURL=chunk-B22X5Y6U.js.map
11537
- //# sourceMappingURL=chunk-B22X5Y6U.js.map
11561
+ //# sourceMappingURL=chunk-5W5FS7JY.js.map
11562
+ //# sourceMappingURL=chunk-5W5FS7JY.js.map