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.
@@ -173,10 +173,12 @@ function deleteQaDatabase(namespace) {
173
173
  // src/config/schema.ts
174
174
  var DEFAULTS = {
175
175
  namespace: "qapture",
176
+ shotPort: 7017,
176
177
  brandLabel: "Qapture",
177
178
  loginField: { en: "Username", ar: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645" },
178
179
  rtl: false,
179
180
  beta: false,
181
+ collector: null,
180
182
  visible: void 0,
181
183
  alwaysVisible: false,
182
184
  hotkey: "shift+alt+q",
@@ -342,6 +344,7 @@ function validateConfig(input) {
342
344
  return {
343
345
  config: {
344
346
  namespace: DEFAULTS.namespace,
347
+ shotPort: DEFAULTS.shotPort,
345
348
  brand: { label: DEFAULTS.brandLabel },
346
349
  loginField: { ...DEFAULTS.loginField },
347
350
  credentials: [],
@@ -349,6 +352,7 @@ function validateConfig(input) {
349
352
  preamble: null,
350
353
  rtl: DEFAULTS.rtl,
351
354
  beta: DEFAULTS.beta,
355
+ collector: null,
352
356
  visible: DEFAULTS.visible,
353
357
  alwaysVisible: DEFAULTS.alwaysVisible,
354
358
  hotkey: DEFAULTS.hotkey,
@@ -363,6 +367,7 @@ function validateConfig(input) {
363
367
  return {
364
368
  config: {
365
369
  namespace: DEFAULTS.namespace,
370
+ shotPort: DEFAULTS.shotPort,
366
371
  brand: { label: DEFAULTS.brandLabel },
367
372
  loginField: { ...DEFAULTS.loginField },
368
373
  credentials: [],
@@ -370,6 +375,7 @@ function validateConfig(input) {
370
375
  preamble: null,
371
376
  rtl: DEFAULTS.rtl,
372
377
  beta: DEFAULTS.beta,
378
+ collector: null,
373
379
  visible: DEFAULTS.visible,
374
380
  alwaysVisible: DEFAULTS.alwaysVisible,
375
381
  hotkey: DEFAULTS.hotkey,
@@ -381,6 +387,8 @@ function validateConfig(input) {
381
387
  }
382
388
  const raw = input;
383
389
  const namespace = isNonEmptyString(raw["namespace"]) ? raw["namespace"].trim() : DEFAULTS.namespace;
390
+ const rawPort = Number(raw["shotPort"]);
391
+ const shotPort = Number.isInteger(rawPort) && rawPort > 0 && rawPort < 65536 ? rawPort : DEFAULTS.shotPort;
384
392
  if (raw["theme"] !== void 0) {
385
393
  warnings.push(
386
394
  '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.'
@@ -406,6 +414,14 @@ function validateConfig(input) {
406
414
  const preamble = raw["preamble"] !== void 0 ? coercePreamble(raw["preamble"]) : null;
407
415
  const rtl = typeof raw["rtl"] === "boolean" ? raw["rtl"] : DEFAULTS.rtl;
408
416
  const beta = typeof raw["beta"] === "boolean" ? raw["beta"] : DEFAULTS.beta;
417
+ const rawCol = raw["collector"];
418
+ const collector = rawCol && typeof rawCol["url"] === "string" && typeof rawCol["token"] === "string" && typeof rawCol["project"] === "string" ? {
419
+ url: rawCol["url"],
420
+ token: rawCol["token"],
421
+ project: rawCol["project"],
422
+ campaign: typeof rawCol["campaign"] === "string" ? rawCol["campaign"] : void 0,
423
+ tester: typeof rawCol["tester"] === "string" ? rawCol["tester"] : void 0
424
+ } : DEFAULTS.collector;
409
425
  const alwaysVisible = typeof raw["alwaysVisible"] === "boolean" ? raw["alwaysVisible"] : DEFAULTS.alwaysVisible;
410
426
  const hotkey = isNonEmptyString(raw["hotkey"]) ? raw["hotkey"].trim() : DEFAULTS.hotkey;
411
427
  const captureHotkey = isNonEmptyString(raw["captureHotkey"]) ? raw["captureHotkey"].trim() : DEFAULTS.captureHotkey;
@@ -422,6 +438,7 @@ function validateConfig(input) {
422
438
  return {
423
439
  config: {
424
440
  namespace,
441
+ shotPort,
425
442
  brand: { label: brandLabel },
426
443
  loginField,
427
444
  credentials,
@@ -429,6 +446,7 @@ function validateConfig(input) {
429
446
  preamble,
430
447
  rtl,
431
448
  beta,
449
+ collector,
432
450
  visible,
433
451
  alwaysVisible,
434
452
  hotkey,
@@ -1706,6 +1724,98 @@ function createStorage(namespace) {
1706
1724
  return { getItem, setItem, getJSON, setJSON };
1707
1725
  }
1708
1726
 
1727
+ // src/lib/faultLog.ts
1728
+ var LIMIT = 40;
1729
+ var faults = [];
1730
+ function recordFault(where, err) {
1731
+ const what = err instanceof Error ? `${err.name}: ${err.message}` : typeof err === "string" ? err : (() => {
1732
+ try {
1733
+ return JSON.stringify(err);
1734
+ } catch {
1735
+ return String(err);
1736
+ }
1737
+ })();
1738
+ faults.push({ at: Date.now(), where, what: what.slice(0, 500) });
1739
+ if (faults.length > LIMIT) faults.splice(0, faults.length - LIMIT);
1740
+ console.warn(`[QA] ${where}:`, err);
1741
+ }
1742
+ function readFaults() {
1743
+ return [...faults].reverse();
1744
+ }
1745
+ function clearFaults() {
1746
+ faults.length = 0;
1747
+ }
1748
+ function faultsAsText(version) {
1749
+ if (!faults.length) return "No faults recorded.";
1750
+ const head = [
1751
+ `qapture ${version}`,
1752
+ typeof navigator !== "undefined" ? navigator.userAgent : "",
1753
+ typeof location !== "undefined" ? location.href.split("?")[0] : "",
1754
+ ""
1755
+ ].filter(Boolean).join("\n");
1756
+ return head + readFaults().map((f) => `${new Date(f.at).toISOString()} [${f.where}] ${f.what}`).join("\n");
1757
+ }
1758
+
1759
+ // src/lib/collector.ts
1760
+ var TIMEOUT_MS = 8e3;
1761
+ var MAX_SHOT_BYTES = 6 * 1024 * 1024;
1762
+ function blobToDataUrl(blob) {
1763
+ return new Promise((resolve) => {
1764
+ try {
1765
+ const reader = new FileReader();
1766
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
1767
+ reader.onerror = () => resolve(null);
1768
+ reader.readAsDataURL(blob);
1769
+ } catch {
1770
+ resolve(null);
1771
+ }
1772
+ });
1773
+ }
1774
+ async function sendToCollector(note, cfg) {
1775
+ if (typeof fetch === "undefined" || !cfg?.url || !cfg.token || !cfg.project) return false;
1776
+ let shot;
1777
+ if (note.screenshot && note.screenshot.size <= MAX_SHOT_BYTES) {
1778
+ shot = await blobToDataUrl(note.screenshot) ?? void 0;
1779
+ }
1780
+ const controller = new AbortController();
1781
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
1782
+ try {
1783
+ const res = await fetch(`${cfg.url.replace(/\/$/, "")}/notes`, {
1784
+ method: "POST",
1785
+ signal: controller.signal,
1786
+ headers: {
1787
+ "content-type": "application/json",
1788
+ authorization: `Bearer ${cfg.token}`
1789
+ },
1790
+ body: JSON.stringify({
1791
+ project: cfg.project,
1792
+ // A campaign per day is the shape that matches how testing actually
1793
+ // happens, and it means nobody has to name anything.
1794
+ campaign: cfg.campaign || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
1795
+ tester: cfg.tester,
1796
+ id: note.id,
1797
+ route: note.route,
1798
+ description: note.description,
1799
+ wanted: note.wanted,
1800
+ why: note.why,
1801
+ severity: note.severity,
1802
+ origin: note.origin,
1803
+ shot
1804
+ })
1805
+ });
1806
+ if (!res.ok) {
1807
+ recordFault("collector", `server answered ${res.status}`);
1808
+ return false;
1809
+ }
1810
+ return true;
1811
+ } catch (err) {
1812
+ recordFault("collector", err);
1813
+ return false;
1814
+ } finally {
1815
+ clearTimeout(timer);
1816
+ }
1817
+ }
1818
+
1709
1819
  // src/lib/strings.ts
1710
1820
  var STR = {
1711
1821
  en: {
@@ -1834,6 +1944,8 @@ var STR = {
1834
1944
  exact_declined: "Staying on redrawn screenshots",
1835
1945
  exact_turn_on: "Turn on",
1836
1946
  exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1947
+ 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.",
1948
+ exact_native_on: "Real screenshots on \u2014 local helper",
1837
1949
  sync_title: "Save to a folder",
1838
1950
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1839
1951
  sync_choose: "Choose folder",
@@ -2071,6 +2183,8 @@ var STR = {
2071
2183
  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",
2072
2184
  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",
2073
2185
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
2186
+ 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.",
2187
+ 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",
2074
2188
  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",
2075
2189
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
2076
2190
  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.",
@@ -2407,13 +2521,132 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2407
2521
  if (sw < 1 || sh < 1) return null;
2408
2522
  return { sx, sy, sw, sh };
2409
2523
  }
2524
+ function environmentSignature() {
2525
+ if (typeof window === "undefined") return "";
2526
+ return [
2527
+ window.innerWidth,
2528
+ window.innerHeight,
2529
+ window.outerWidth,
2530
+ window.outerHeight,
2531
+ window.screenX,
2532
+ window.screenY,
2533
+ Math.round((window.devicePixelRatio || 1) * 100)
2534
+ ].join("x");
2535
+ }
2536
+
2537
+ // src/lib/nativeShot.ts
2538
+ var DEFAULT_SHOT_PORT = 7017;
2539
+ var PROBE_TIMEOUT_MS = 700;
2540
+ var SHOT_TIMEOUT_MS = 9e3;
2541
+ var ABSENT_RECHECK_MS = 3e4;
2542
+ var configuredPort = DEFAULT_SHOT_PORT;
2543
+ function setShotPort(port) {
2544
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return;
2545
+ if (port === configuredPort) return;
2546
+ configuredPort = port;
2547
+ resetNativeShotProbe();
2548
+ }
2549
+ var cachedBase = null;
2550
+ var lastProbeAt = 0;
2551
+ var lastProbeResult = false;
2552
+ function baseUrl(port) {
2553
+ return `http://127.0.0.1:${port}`;
2554
+ }
2555
+ async function fetchWithTimeout(url, init, ms) {
2556
+ if (typeof fetch !== "function") return null;
2557
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
2558
+ const timer = setTimeout(() => ac?.abort(), ms);
2559
+ try {
2560
+ return await fetch(url, { ...init, signal: ac?.signal, cache: "no-store" });
2561
+ } catch {
2562
+ return null;
2563
+ } finally {
2564
+ clearTimeout(timer);
2565
+ }
2566
+ }
2567
+ async function isNativeShotAvailable(port = configuredPort) {
2568
+ if (typeof window === "undefined") return false;
2569
+ const now2 = Date.now();
2570
+ if (lastProbeResult && cachedBase) return true;
2571
+ if (!lastProbeResult && now2 - lastProbeAt < ABSENT_RECHECK_MS) return false;
2572
+ lastProbeAt = now2;
2573
+ const res = await fetchWithTimeout(`${baseUrl(port)}/qapture/health`, { method: "GET" }, PROBE_TIMEOUT_MS);
2574
+ lastProbeResult = !!res && res.ok;
2575
+ cachedBase = lastProbeResult ? baseUrl(port) : null;
2576
+ return lastProbeResult;
2577
+ }
2578
+ function resetNativeShotProbe() {
2579
+ cachedBase = null;
2580
+ lastProbeAt = 0;
2581
+ lastProbeResult = false;
2582
+ }
2583
+ async function shootBrowserWindow(port = configuredPort) {
2584
+ if (typeof window === "undefined") return null;
2585
+ const base = cachedBase ?? baseUrl(port);
2586
+ const body = JSON.stringify({
2587
+ x: window.screenX,
2588
+ y: window.screenY,
2589
+ w: window.outerWidth,
2590
+ h: window.outerHeight
2591
+ });
2592
+ const res = await fetchWithTimeout(
2593
+ `${base}/qapture/shot`,
2594
+ { method: "POST", headers: { "content-type": "application/json" }, body },
2595
+ SHOT_TIMEOUT_MS
2596
+ );
2597
+ if (!res || !res.ok) return null;
2598
+ let payload;
2599
+ try {
2600
+ payload = await res.json();
2601
+ } catch {
2602
+ return null;
2603
+ }
2604
+ if (!payload.png) return null;
2605
+ return decodeToCanvas(payload.png);
2606
+ }
2607
+ function decodeToCanvas(dataUrl) {
2608
+ return new Promise((resolve) => {
2609
+ const img = new Image();
2610
+ img.onload = () => {
2611
+ const c = document.createElement("canvas");
2612
+ c.width = img.naturalWidth;
2613
+ c.height = img.naturalHeight;
2614
+ const ctx = c.getContext("2d", { willReadFrequently: true });
2615
+ if (!ctx) {
2616
+ resolve(null);
2617
+ return;
2618
+ }
2619
+ ctx.drawImage(img, 0, 0);
2620
+ resolve(c);
2621
+ };
2622
+ img.onerror = () => resolve(null);
2623
+ img.src = dataUrl;
2624
+ });
2625
+ }
2626
+ var cachedMapping = null;
2627
+ var cachedFor = "";
2628
+ function getCachedMapping() {
2629
+ if (!cachedMapping) return null;
2630
+ return environmentSignature() === cachedFor ? cachedMapping : null;
2631
+ }
2632
+ function cacheMapping(m) {
2633
+ cachedMapping = m;
2634
+ cachedFor = m ? environmentSignature() : "";
2635
+ }
2410
2636
 
2411
2637
  // src/lib/screenCapture.ts
2412
2638
  var ASPECT_TOLERANCE = 0.08;
2413
2639
  var CALIBRATION_SETTLE_MS = 220;
2414
2640
  var FRESH_FRAME_TIMEOUT_MS = 500;
2415
2641
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2642
+ var nativeReady = false;
2643
+ async function refreshNativeAvailability(port) {
2644
+ if (typeof port === "number") setShotPort(port);
2645
+ nativeReady = await isNativeShotAvailable();
2646
+ return nativeReady;
2647
+ }
2416
2648
  function isExactCaptureSupported() {
2649
+ if (nativeReady) return true;
2417
2650
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2418
2651
  const md = navigator.mediaDevices;
2419
2652
  return !!md && typeof md.getDisplayMedia === "function";
@@ -2421,13 +2654,15 @@ function isExactCaptureSupported() {
2421
2654
  var armed = false;
2422
2655
  var declined = false;
2423
2656
  var frozen = null;
2424
- var lastMode = null;
2425
2657
  function armExactCapture() {
2426
2658
  if (!isExactCaptureSupported()) return false;
2427
2659
  armed = true;
2428
2660
  declined = false;
2429
2661
  return true;
2430
2662
  }
2663
+ function exactCaptureIsFree() {
2664
+ return nativeReady;
2665
+ }
2431
2666
  function disarmExactCapture() {
2432
2667
  armed = false;
2433
2668
  releaseFrozenFrame();
@@ -2436,6 +2671,7 @@ function resetExactCaptureDecline() {
2436
2671
  declined = false;
2437
2672
  }
2438
2673
  function getExactCaptureStatus() {
2674
+ if (nativeReady) return "native";
2439
2675
  if (!isExactCaptureSupported()) return "unsupported";
2440
2676
  if (armed) return "live";
2441
2677
  if (declined) return "declined";
@@ -2460,7 +2696,7 @@ function stillIsCurrent() {
2460
2696
  return true;
2461
2697
  }
2462
2698
  async function freezeOrReuse() {
2463
- if (stillIsCurrent()) return frozen;
2699
+ if (!nativeReady && stillIsCurrent()) return frozen;
2464
2700
  return freezeViewport();
2465
2701
  }
2466
2702
  function releaseFrozenFrame() {
@@ -2560,7 +2796,7 @@ async function grabFrameCanvas(video, grabber) {
2560
2796
  bitmap?.close?.();
2561
2797
  return c;
2562
2798
  }
2563
- async function calibrate(video, grabber) {
2799
+ async function calibrate(grabFrame) {
2564
2800
  const vw = window.innerWidth;
2565
2801
  const vh = window.innerHeight;
2566
2802
  if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
@@ -2577,7 +2813,7 @@ async function calibrate(video, grabber) {
2577
2813
  document.body.appendChild(card);
2578
2814
  try {
2579
2815
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2580
- const frame = await grabFrameCanvas(video, grabber);
2816
+ const frame = await grabFrame();
2581
2817
  if (!frame) return null;
2582
2818
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2583
2819
  if (!ctx) return null;
@@ -2599,8 +2835,69 @@ async function calibrate(video, grabber) {
2599
2835
  }
2600
2836
  }
2601
2837
  async function freezeViewport() {
2602
- if (!isExactCaptureSupported()) return null;
2603
2838
  releaseFrozenFrame();
2839
+ const native = await freezeViaNativeHelper();
2840
+ if (native) return native;
2841
+ if (!isExactCaptureSupported()) return null;
2842
+ return freezeViaDisplayMedia();
2843
+ }
2844
+ function adoptFrame(raw, mapping, mode, vw, vh) {
2845
+ let page;
2846
+ if (mapping) {
2847
+ const box = mapRectToFrame(
2848
+ { left: 0, top: 0, width: vw, height: vh },
2849
+ mapping,
2850
+ raw.width,
2851
+ raw.height
2852
+ );
2853
+ if (!box) return null;
2854
+ page = document.createElement("canvas");
2855
+ page.width = box.sw;
2856
+ page.height = box.sh;
2857
+ const ctx = page.getContext("2d");
2858
+ if (!ctx) return null;
2859
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2860
+ raw.width = 0;
2861
+ raw.height = 0;
2862
+ } else {
2863
+ page = raw;
2864
+ }
2865
+ frozen = {
2866
+ canvas: page,
2867
+ mode,
2868
+ viewportWidth: vw,
2869
+ viewportHeight: vh,
2870
+ takenAt: Date.now()
2871
+ };
2872
+ frozenAtScrollX = window.scrollX;
2873
+ frozenAtScrollY = window.scrollY;
2874
+ frozenAtPath = window.location.pathname + window.location.search;
2875
+ return frozen;
2876
+ }
2877
+ async function freezeViaNativeHelper() {
2878
+ if (typeof window === "undefined") return null;
2879
+ nativeReady = await isNativeShotAvailable();
2880
+ if (!nativeReady) return null;
2881
+ const vw = window.innerWidth;
2882
+ const vh = window.innerHeight;
2883
+ let mapping = getCachedMapping();
2884
+ if (!mapping) {
2885
+ mapping = await calibrate(() => shootBrowserWindow());
2886
+ if (!mapping) {
2887
+ cacheMapping(null);
2888
+ return null;
2889
+ }
2890
+ cacheMapping(mapping);
2891
+ }
2892
+ const raw = await withOverlayHidden(() => shootBrowserWindow());
2893
+ if (!raw) return null;
2894
+ const adopted = adoptFrame(raw, mapping, "native", vw, vh);
2895
+ if (!adopted) {
2896
+ cacheMapping(null);
2897
+ }
2898
+ return adopted;
2899
+ }
2900
+ async function freezeViaDisplayMedia() {
2604
2901
  let stream = null;
2605
2902
  let el = null;
2606
2903
  const release = () => {
@@ -2673,45 +2970,14 @@ async function freezeViewport() {
2673
2970
  const vw = window.innerWidth;
2674
2971
  const vh = window.innerHeight;
2675
2972
  const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2676
- const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2973
+ const mapping = mode === "surface" ? await calibrate(() => grabFrameCanvas(video, grabber)) : null;
2677
2974
  if (mode === "surface" && !mapping) {
2678
2975
  declined = true;
2679
2976
  return null;
2680
2977
  }
2681
2978
  const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2682
2979
  if (!raw) return null;
2683
- let page;
2684
- if (mode === "surface" && mapping) {
2685
- const box = mapRectToFrame(
2686
- { left: 0, top: 0, width: vw, height: vh },
2687
- mapping,
2688
- raw.width,
2689
- raw.height
2690
- );
2691
- if (!box) return null;
2692
- page = document.createElement("canvas");
2693
- page.width = box.sw;
2694
- page.height = box.sh;
2695
- const ctx = page.getContext("2d");
2696
- if (!ctx) return null;
2697
- ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2698
- raw.width = 0;
2699
- raw.height = 0;
2700
- } else {
2701
- page = raw;
2702
- }
2703
- frozen = {
2704
- canvas: page,
2705
- mode,
2706
- viewportWidth: vw,
2707
- viewportHeight: vh,
2708
- takenAt: Date.now()
2709
- };
2710
- frozenAtScrollX = window.scrollX;
2711
- frozenAtScrollY = window.scrollY;
2712
- frozenAtPath = window.location.pathname + window.location.search;
2713
- lastMode = mode;
2714
- return frozen;
2980
+ return adoptFrame(raw, mode === "surface" ? mapping : null, mode, vw, vh);
2715
2981
  } catch {
2716
2982
  declined = true;
2717
2983
  return null;
@@ -2881,38 +3147,6 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2881
3147
  return touched;
2882
3148
  }
2883
3149
 
2884
- // src/lib/faultLog.ts
2885
- var LIMIT = 40;
2886
- var faults = [];
2887
- function recordFault(where, err) {
2888
- const what = err instanceof Error ? `${err.name}: ${err.message}` : typeof err === "string" ? err : (() => {
2889
- try {
2890
- return JSON.stringify(err);
2891
- } catch {
2892
- return String(err);
2893
- }
2894
- })();
2895
- faults.push({ at: Date.now(), where, what: what.slice(0, 500) });
2896
- if (faults.length > LIMIT) faults.splice(0, faults.length - LIMIT);
2897
- console.warn(`[QA] ${where}:`, err);
2898
- }
2899
- function readFaults() {
2900
- return [...faults].reverse();
2901
- }
2902
- function clearFaults() {
2903
- faults.length = 0;
2904
- }
2905
- function faultsAsText(version) {
2906
- if (!faults.length) return "No faults recorded.";
2907
- const head = [
2908
- `qapture ${version}`,
2909
- typeof navigator !== "undefined" ? navigator.userAgent : "",
2910
- typeof location !== "undefined" ? location.href.split("?")[0] : "",
2911
- ""
2912
- ].filter(Boolean).join("\n");
2913
- return head + readFaults().map((f) => `${new Date(f.at).toISOString()} [${f.where}] ${f.what}`).join("\n");
2914
- }
2915
-
2916
3150
  // src/lib/capture.ts
2917
3151
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2918
3152
  var CHUNK_TIMEOUT_MS = 8e3;
@@ -3355,7 +3589,7 @@ function noteCheckLine(note, index) {
3355
3589
  const where = oneLine(note.route) || "/";
3356
3590
  const wanted = oneLine(note.wanted);
3357
3591
  const seen = oneLine(note.description) || "(not described)";
3358
- const claim = wanted || `${seen} \u2014 _no expectation was given; ask before assuming one_`;
3592
+ const claim = wanted || seen;
3359
3593
  const trimmed = claim.length > 180 ? `${claim.slice(0, 177)}...` : claim;
3360
3594
  return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3361
3595
  }
@@ -3405,12 +3639,12 @@ function noteToMarkdown(note, opts) {
3405
3639
  lines.push("### Observed");
3406
3640
  lines.push("");
3407
3641
  lines.push(oneLine(note.description) ? note.description.trim() : "_(not described)_");
3408
- lines.push("");
3409
- lines.push("### Expected");
3410
- lines.push("");
3411
- lines.push(
3412
- note.wanted && oneLine(note.wanted) ? note.wanted.trim() : "_(the tester did not say what they expected instead -- ask rather than assume)_"
3413
- );
3642
+ if (note.wanted && oneLine(note.wanted)) {
3643
+ lines.push("");
3644
+ lines.push("### Expected");
3645
+ lines.push("");
3646
+ lines.push(note.wanted.trim());
3647
+ }
3414
3648
  if (note.why && oneLine(note.why)) {
3415
3649
  lines.push("");
3416
3650
  lines.push("### Why it matters");
@@ -3564,11 +3798,24 @@ function reproSpec(note, index) {
3564
3798
  }
3565
3799
 
3566
3800
  // src/lib/exportZip.ts
3567
- function safeName(name, stamp) {
3568
- const fallback = `qa-notes-${stamp.slice(0, 10)}`;
3801
+ function autoName(project, stamp) {
3802
+ const date = stamp.slice(0, 10);
3803
+ const time = stamp.slice(11, 16).replace(":", "");
3804
+ const slug = (project ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3805
+ const when = time ? `${date}-${time}` : date;
3806
+ return slug ? `${slug}-qa-${when}` : `qa-notes-${when}`;
3807
+ }
3808
+ function exportProjectName(config) {
3809
+ const name = config?.preamble?.projectName;
3810
+ return typeof name === "string" && name.trim() ? name.trim() : void 0;
3811
+ }
3812
+ function safeName(name, stamp, project) {
3569
3813
  let base = (name ?? "").trim().replace(/\.zip$/i, "");
3570
3814
  base = base.replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80).trim();
3571
- return `${base || fallback}.zip`;
3815
+ return `${base || autoName(project, stamp)}.zip`;
3816
+ }
3817
+ function suggestedExportName(project, stamp) {
3818
+ return autoName(project, stamp);
3572
3819
  }
3573
3820
  function toStrings(val) {
3574
3821
  if (val == null) return [];
@@ -3700,7 +3947,8 @@ Hand \`verify.md\` back with the work.
3700
3947
 
3701
3948
  ### What is in this archive
3702
3949
 
3703
- - \`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**.
3950
+ - \`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.
3951
+ 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.
3704
3952
  - \`verify.md\` \u2014 the checklist, one unticked box per point.
3705
3953
  - \`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.
3706
3954
  - \`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.
@@ -3945,14 +4193,14 @@ async function buildAndDownloadZip(notes, stamp, filename, config, guideChecked,
3945
4193
  const url = URL.createObjectURL(blob);
3946
4194
  const a = document.createElement("a");
3947
4195
  a.href = url;
3948
- a.download = safeName(filename, stamp);
4196
+ a.download = safeName(filename, stamp, exportProjectName(config));
3949
4197
  document.body.appendChild(a);
3950
4198
  a.click();
3951
4199
  a.remove();
3952
4200
  setTimeout(() => URL.revokeObjectURL(url), 2e3);
3953
4201
  }
3954
- function exportFileName(filename, stamp) {
3955
- return safeName(filename, stamp);
4202
+ function exportFileName(filename, stamp, project) {
4203
+ return safeName(filename, stamp, project);
3956
4204
  }
3957
4205
 
3958
4206
  // src/lib/shareZip.ts
@@ -4525,18 +4773,6 @@ async function requestPersistentStorage() {
4525
4773
  return false;
4526
4774
  }
4527
4775
  }
4528
- function formatBytes(bytes) {
4529
- if (!Number.isFinite(bytes) || bytes <= 0) return "0 KB";
4530
- const units = ["B", "KB", "MB", "GB", "TB"];
4531
- let value = bytes;
4532
- let i = 0;
4533
- while (value >= 1024 && i < units.length - 1) {
4534
- value /= 1024;
4535
- i++;
4536
- }
4537
- const decimals = value < 10 && i > 1 ? 1 : 0;
4538
- return `${value.toFixed(decimals)} ${units[i]}`;
4539
- }
4540
4776
  function estimateOwnBytes(notes) {
4541
4777
  let total = 0;
4542
4778
  for (const n of notes) {
@@ -4662,7 +4898,23 @@ function QaProvider({
4662
4898
  if (exactShotsWanted(storage)) armExactCapture();
4663
4899
  return getExactCaptureStatus();
4664
4900
  });
4665
- const exactSupported = isExactCaptureSupported();
4901
+ const [exactSupported, setExactSupported] = React.useState(() => isExactCaptureSupported());
4902
+ React.useEffect(() => {
4903
+ let alive = true;
4904
+ const look = () => {
4905
+ void refreshNativeAvailability(config.shotPort).then(() => {
4906
+ if (!alive) return;
4907
+ setExactSupported(isExactCaptureSupported());
4908
+ setExactStatus(getExactCaptureStatus());
4909
+ });
4910
+ };
4911
+ look();
4912
+ window.addEventListener("focus", look);
4913
+ return () => {
4914
+ alive = false;
4915
+ window.removeEventListener("focus", look);
4916
+ };
4917
+ }, [config.shotPort]);
4666
4918
  const [frozenAt, setFrozenAt] = React.useState(null);
4667
4919
  const [syncState, setSyncState] = React.useState(() => getFsSyncState());
4668
4920
  const [syncTick, setSyncTick] = React.useState(0);
@@ -5038,8 +5290,11 @@ function QaProvider({
5038
5290
  });
5039
5291
  }
5040
5292
  await syncNoteThrough(note);
5293
+ if (config.collector) {
5294
+ void sendToCollector(note, config.collector);
5295
+ }
5041
5296
  },
5042
- [idb, config.journey, config.captureContext, testAlong, testAlongSteps, notify, t, syncNoteThrough, applyNotes]
5297
+ [idb, config.journey, config.captureContext, config.collector, testAlong, testAlongSteps, notify, t, syncNoteThrough, applyNotes]
5043
5298
  );
5044
5299
  const updateNote = React.useCallback(
5045
5300
  async (id, patch) => {
@@ -5185,7 +5440,7 @@ function QaProvider({
5185
5440
  setCapturePrefill(prefill ?? "");
5186
5441
  setCaptureActive(true);
5187
5442
  if (!stillIsCurrent()) setFrozenAt(null);
5188
- if (exactShotsWanted(storage) && isExactCaptureSupported()) {
5443
+ if (exactCaptureIsFree() || exactShotsWanted(storage) && isExactCaptureSupported()) {
5189
5444
  resetExactCaptureDecline();
5190
5445
  void freezeOrReuse().then((frame) => {
5191
5446
  setFrozenAt(frame?.takenAt ?? null);
@@ -5503,7 +5758,7 @@ function QaProvider({
5503
5758
  const shareExport = React.useCallback(async (filename) => {
5504
5759
  if (!notes.length) return { status: "unsupported" };
5505
5760
  const stamp = nowIso();
5506
- const name = exportFileName(filename, stamp);
5761
+ const name = exportFileName(filename, stamp, exportProjectName(config));
5507
5762
  setIsExporting(true);
5508
5763
  try {
5509
5764
  const blob = await buildZipBlob(notes, stamp, config, guideChecked, guideSkipped);
@@ -5877,6 +6132,7 @@ function QaProvider({
5877
6132
  setSimpleMode,
5878
6133
  compactCapture,
5879
6134
  developerMode,
6135
+ projectName: exportProjectName(config),
5880
6136
  setDeveloperMode,
5881
6137
  setCompactCapture,
5882
6138
  exportZip: exportZipFn
@@ -8150,7 +8406,7 @@ function upgradeHint(latest) {
8150
8406
  }
8151
8407
 
8152
8408
  // src/version.ts
8153
- var QA_VERSION = "0.9.0" ;
8409
+ var QA_VERSION = "0.10.0" ;
8154
8410
  function Section({
8155
8411
  icon,
8156
8412
  title,
@@ -8256,8 +8512,8 @@ function SettingsSheet({ onClose }) {
8256
8512
  const syncing = sync.state === "syncing";
8257
8513
  const viaZip = sync.engine === "download";
8258
8514
  const quotaKnown = storageHealth.supported && storageHealth.quotaBytes > 0;
8259
- const usedPct = quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
8260
- const meterColor = storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
8515
+ quotaKnown ? Math.min(100, Math.round(storageHealth.ratio * 100)) : 0;
8516
+ storageHealth.level === "critical" ? "var(--qa-danger)" : storageHealth.level === "warn" ? "var(--qa-warn)" : "var(--qa-accent)";
8261
8517
  return /* @__PURE__ */ jsxRuntime.jsxs(
8262
8518
  "div",
8263
8519
  {
@@ -8386,81 +8642,13 @@ function SettingsSheet({ onClose }) {
8386
8642
  )
8387
8643
  ] }),
8388
8644
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8389
- /* @__PURE__ */ jsxRuntime.jsxs(Section, { icon: "HardDrive", title: t("storage_title"), children: [
8390
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("storage_explain") }),
8391
- quotaKnown && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8392
- /* @__PURE__ */ jsxRuntime.jsx(
8393
- "div",
8394
- {
8395
- className: "qa-w-full qa-rounded-full qa-overflow-hidden qa-bg-3",
8396
- role: "img",
8397
- "aria-label": t("storage_used", {
8398
- used: formatBytes(storageHealth.usageBytes),
8399
- quota: formatBytes(storageHealth.quotaBytes)
8400
- }),
8401
- style: { height: 6 },
8402
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: { width: `${usedPct}%`, height: "100%", background: meterColor } })
8403
- }
8404
- ),
8405
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: [
8406
- t("storage_used", {
8407
- used: formatBytes(storageHealth.usageBytes),
8408
- quota: formatBytes(storageHealth.quotaBytes)
8409
- }),
8410
- " \xB7 ",
8411
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-text-lo", children: [
8412
- "Qapture ",
8413
- formatBytes(storageHealth.ownBytes)
8414
- ] })
8415
- ] })
8416
- ] }),
8417
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-items-start qa-gap-2 qa-text-xs qa-text-hi", style: { cursor: "pointer" }, children: [
8418
- /* @__PURE__ */ jsxRuntime.jsx(
8419
- "input",
8420
- {
8421
- type: "checkbox",
8422
- checked: autoBackup,
8423
- onChange: (e) => setAutoBackup(e.target.checked),
8424
- style: { marginTop: 2 }
8425
- }
8426
- ),
8427
- /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
8428
- t("autosave_label"),
8429
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-block qa-text-10 qa-text-lo qa-leading-relaxed", children: t("autosave_hint", { n: autoBackupEvery }) })
8430
- ] })
8431
- ] }),
8432
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex qa-flex-wrap qa-gap-2", children: [
8433
- storageHealth.persisted ? /* @__PURE__ */ jsxRuntime.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: [
8434
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "CheckCircle2", size: 11 }),
8435
- t("persist_on")
8436
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(
8437
- "button",
8438
- {
8439
- type: "button",
8440
- disabled: busy,
8441
- onClick: () => void run2(requestPersistentStorage2),
8442
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
8443
- style: { background: "transparent", cursor: "pointer" },
8444
- children: t("persist_keep")
8445
- }
8446
- ),
8447
- notes.some((n) => n.screenshot) && /* @__PURE__ */ jsxRuntime.jsx(
8448
- "button",
8449
- {
8450
- type: "button",
8451
- disabled: busy,
8452
- onClick: () => void run2(dropAllScreenshots),
8453
- className: "qa-tap qa-rounded-lg qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-10 qa-text-mid",
8454
- style: { background: "transparent", cursor: "pointer" },
8455
- children: t("drop_shots")
8456
- }
8457
- )
8458
- ] })
8459
- ] }),
8460
8645
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8461
8646
  /* @__PURE__ */ jsxRuntime.jsxs(Section, { icon: "Camera", title: t("exact_label"), children: [
8462
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-lo qa-leading-relaxed", children: t("exact_hint") }),
8463
- !exactShots.supported ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxRuntime.jsxs(
8647
+ /* @__PURE__ */ jsxRuntime.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") }),
8648
+ exactShots.status === "native" ? /* @__PURE__ */ jsxRuntime.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: [
8649
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "CheckCircle2", size: 13 }),
8650
+ t("exact_native_on")
8651
+ ] }) : !exactShots.supported ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-m-0 qa-text-10 qa-text-mid", children: t("exact_unsupported") }) : exactShots.status === "live" ? /* @__PURE__ */ jsxRuntime.jsxs(
8464
8652
  "button",
8465
8653
  {
8466
8654
  type: "button",
@@ -8834,8 +9022,8 @@ var TABS = ALL_TABS.filter((tab) => tab.key !== "guide" || GUIDE_TAB_ENABLED);
8834
9022
  function visibleTab(tab) {
8835
9023
  return tab === "guide" && !GUIDE_TAB_ENABLED ? "notes" : tab;
8836
9024
  }
8837
- function todayName() {
8838
- return `qa-notes-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
9025
+ function suggestName(project) {
9026
+ return suggestedExportName(project, (/* @__PURE__ */ new Date()).toISOString());
8839
9027
  }
8840
9028
  function panelReducer(state2, action) {
8841
9029
  switch (action.type) {
@@ -8897,7 +9085,8 @@ function QaPanel() {
8897
9085
  panelSide,
8898
9086
  setPanelSide,
8899
9087
  panelCollapsed,
8900
- setPanelCollapsed
9088
+ setPanelCollapsed,
9089
+ projectName
8901
9090
  } = useQa();
8902
9091
  const activeTab = visibleTab(storedTab);
8903
9092
  const [confirmClear, setConfirmClear] = React.useState(false);
@@ -9016,7 +9205,7 @@ function QaPanel() {
9016
9205
  const appliedKeyboardLift = keyboardLiftActive ? keyboardLift : 0;
9017
9206
  if (phase === "hidden") return null;
9018
9207
  const openNaming = () => {
9019
- setFilename(todayName());
9208
+ setFilename(suggestName(projectName));
9020
9209
  setNaming(true);
9021
9210
  };
9022
9211
  const doExport = () => {
@@ -10021,98 +10210,6 @@ function findDuplicate(text, selector, existing) {
10021
10210
  }
10022
10211
  return null;
10023
10212
  }
10024
- function recogniser() {
10025
- if (typeof window === "undefined") return null;
10026
- const w = window;
10027
- return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
10028
- }
10029
- function VoiceButton({
10030
- onText,
10031
- onInterim
10032
- }) {
10033
- const { lang, t } = useQa();
10034
- const [listening, setListening] = React.useState(false);
10035
- const [failed, setFailed] = React.useState(null);
10036
- const ref = React.useRef(null);
10037
- React.useEffect(() => () => {
10038
- ref.current?.abort();
10039
- ref.current = null;
10040
- }, []);
10041
- const Ctor = recogniser();
10042
- if (!Ctor) return null;
10043
- const stop = () => {
10044
- ref.current?.stop();
10045
- ref.current = null;
10046
- setListening(false);
10047
- onInterim?.("");
10048
- };
10049
- const start = () => {
10050
- if (listening) {
10051
- stop();
10052
- return;
10053
- }
10054
- setFailed(null);
10055
- let rec;
10056
- try {
10057
- rec = new Ctor();
10058
- } catch {
10059
- setFailed(t("voice_failed"));
10060
- return;
10061
- }
10062
- rec.lang = lang === "ar" ? "ar-IQ" : "en-US";
10063
- rec.continuous = true;
10064
- rec.interimResults = true;
10065
- rec.onresult = (e) => {
10066
- let done = "";
10067
- let partial = "";
10068
- for (let i = e.resultIndex; i < e.results.length; i++) {
10069
- const r = e.results[i];
10070
- const text = r[0]?.transcript ?? "";
10071
- if (r.isFinal) done += text;
10072
- else partial += text;
10073
- }
10074
- if (done.trim()) onText(done.trim());
10075
- onInterim?.(partial);
10076
- };
10077
- rec.onerror = (e) => {
10078
- if (e.error && e.error !== "no-speech" && e.error !== "aborted") {
10079
- setFailed(e.error === "not-allowed" ? t("voice_denied") : t("voice_failed"));
10080
- }
10081
- setListening(false);
10082
- onInterim?.("");
10083
- };
10084
- rec.onend = () => {
10085
- setListening(false);
10086
- onInterim?.("");
10087
- };
10088
- try {
10089
- rec.start();
10090
- ref.current = rec;
10091
- setListening(true);
10092
- } catch {
10093
- setFailed(t("voice_failed"));
10094
- }
10095
- };
10096
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-inline-flex qa-items-center qa-gap-1.5", children: [
10097
- /* @__PURE__ */ jsxRuntime.jsxs(
10098
- "button",
10099
- {
10100
- type: "button",
10101
- onClick: start,
10102
- "aria-pressed": listening,
10103
- title: listening ? t("voice_stop") : t("voice_start"),
10104
- "data-qa-voice": listening ? "listening" : "idle",
10105
- 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"}`,
10106
- style: { cursor: "pointer" },
10107
- children: [
10108
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: listening ? "Square" : "Mic", size: 12 }),
10109
- listening ? t("voice_stop") : t("voice_start")
10110
- ]
10111
- }
10112
- ),
10113
- failed && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-10 qa-text-danger", children: failed })
10114
- ] });
10115
- }
10116
10213
  var DRAG_THRESHOLD2 = 6;
10117
10214
  var TOUCH_DRAG_THRESHOLD = 12;
10118
10215
  var MIN_REGION_SIZE = 8;
@@ -10184,11 +10281,7 @@ function CaptureMode() {
10184
10281
  const [shot, setShot] = React.useState(null);
10185
10282
  const stillRef = React.useRef(null);
10186
10283
  const [notesFromThisShot, setNotesFromThisShot] = React.useState(0);
10187
- const [wanted, setWanted] = React.useState("");
10188
- const [why, setWhy] = React.useState("");
10189
- const [fixHint, setFixHint] = React.useState("");
10190
10284
  const [origin, setOrigin] = React.useState(void 0);
10191
- const [spoken, setSpoken] = React.useState("");
10192
10285
  const [shotEngine, setShotEngine] = React.useState(null);
10193
10286
  const [shotUrl, setShotUrl] = React.useState(null);
10194
10287
  const [capturing, setCapturing] = React.useState(false);
@@ -10508,9 +10601,6 @@ function CaptureMode() {
10508
10601
  setShot(null);
10509
10602
  setShotEngine(null);
10510
10603
  setDescription("");
10511
- setWanted("");
10512
- setWhy("");
10513
- setFixHint("");
10514
10604
  setSeverity("bug");
10515
10605
  setTargetForensics(void 0);
10516
10606
  setOrigin(void 0);
@@ -10533,9 +10623,6 @@ function CaptureMode() {
10533
10623
  };
10534
10624
  await addNote({
10535
10625
  description,
10536
- wanted,
10537
- why,
10538
- fixHint,
10539
10626
  screenshot: shot ?? void 0,
10540
10627
  shotEngine: shotEngine ?? void 0,
10541
10628
  target,
@@ -11171,7 +11258,7 @@ function CaptureMode() {
11171
11258
  }
11172
11259
  ),
11173
11260
  developerMode && /* @__PURE__ */ jsxRuntime.jsx(LocationReveal, { target: selection }),
11174
- developerMode && /* @__PURE__ */ jsxRuntime.jsxs(
11261
+ /* @__PURE__ */ jsxRuntime.jsxs(
11175
11262
  "div",
11176
11263
  {
11177
11264
  role: "group",
@@ -11208,87 +11295,24 @@ function CaptureMode() {
11208
11295
  twin.description.length > 70 ? "\u2026" : "",
11209
11296
  "\u201D"
11210
11297
  ] }),
11211
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11212
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11213
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-11 qa-font-semibold qa-text-hi", children: t("q_observed") }),
11214
- /* @__PURE__ */ jsxRuntime.jsx(
11215
- VoiceButton,
11216
- {
11217
- onText: (text) => setDescription((d) => d ? `${d} ${text}` : text),
11218
- onInterim: setSpoken
11298
+ /* @__PURE__ */ jsxRuntime.jsx(
11299
+ "textarea",
11300
+ {
11301
+ ref: taRef,
11302
+ value: description,
11303
+ onChange: (e) => setDescription(e.target.value),
11304
+ onKeyDown: (e) => {
11305
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11306
+ if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
11307
+ e.preventDefault();
11308
+ setSeverity(SEVERITIES2[Number(e.key) - 1]);
11219
11309
  }
11220
- )
11221
- ] }),
11222
- /* @__PURE__ */ jsxRuntime.jsx(
11223
- "textarea",
11224
- {
11225
- ref: taRef,
11226
- value: description + (spoken ? ` ${spoken}` : ""),
11227
- onChange: (e) => setDescription(e.target.value),
11228
- onKeyDown: (e) => {
11229
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11230
- if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
11231
- e.preventDefault();
11232
- setSeverity(SEVERITIES2[Number(e.key) - 1]);
11233
- }
11234
- },
11235
- rows: 2,
11236
- placeholder: t("q_observed_hint"),
11237
- 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"
11238
- }
11239
- )
11240
- ] }),
11241
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11242
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11243
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-11 qa-font-semibold qa-text-hi", children: t("q_wanted") }),
11244
- /* @__PURE__ */ jsxRuntime.jsx(VoiceButton, { onText: (x) => setWanted((d) => d ? `${d} ${x}` : x) })
11245
- ] }),
11246
- /* @__PURE__ */ jsxRuntime.jsx(
11247
- "textarea",
11248
- {
11249
- value: wanted,
11250
- onChange: (e) => setWanted(e.target.value),
11251
- onKeyDown: (e) => {
11252
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11253
- },
11254
- rows: 2,
11255
- placeholder: t("q_wanted_hint"),
11256
- 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"
11257
- }
11258
- )
11259
- ] }),
11260
- /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11261
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-justify-between qa-gap-2", children: [
11262
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-11 qa-text-mid", children: t("q_why") }),
11263
- /* @__PURE__ */ jsxRuntime.jsx(VoiceButton, { onText: (x) => setWhy((d) => d ? `${d} ${x}` : x) })
11264
- ] }),
11265
- /* @__PURE__ */ jsxRuntime.jsx(
11266
- "textarea",
11267
- {
11268
- value: why,
11269
- onChange: (e) => setWhy(e.target.value),
11270
- onKeyDown: (e) => {
11271
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
11272
- },
11273
- rows: 1,
11274
- placeholder: t("q_why_hint"),
11275
- 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"
11276
- }
11277
- )
11278
- ] }),
11279
- developerMode && /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-flex-col qa-gap-1", children: [
11280
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-11 qa-text-mid", children: t("q_fix") }),
11281
- /* @__PURE__ */ jsxRuntime.jsx(
11282
- "textarea",
11283
- {
11284
- value: fixHint,
11285
- onChange: (e) => setFixHint(e.target.value),
11286
- rows: 1,
11287
- placeholder: t("q_fix_hint"),
11288
- 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"
11289
- }
11290
- )
11291
- ] }),
11310
+ },
11311
+ rows: 3,
11312
+ placeholder: t("annotate_placeholder"),
11313
+ 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"
11314
+ }
11315
+ ),
11292
11316
  developerMode && origin && (origin.component || origin.file) && /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-text-10 qa-text-mid", "data-qa-origin": "true", children: [
11293
11317
  origin.component ?? "\u2014",
11294
11318
  origin.file ? ` \xB7 ${origin.file}${origin.line ? `:${origin.line}` : ""}` : ""
@@ -11334,7 +11358,8 @@ function CaptureMode() {
11334
11358
  ] }),
11335
11359
  /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-text-center qa-text-10 qa-text-slate-400", children: [
11336
11360
  t("save_hint"),
11337
- developerMode ? ` \xB7 ${t("severity_keys")}` : ""
11361
+ " \xB7 ",
11362
+ t("severity_keys")
11338
11363
  ] })
11339
11364
  ] })
11340
11365
  ]
@@ -11542,5 +11567,5 @@ function Qapture({ config }) {
11542
11567
  exports.Qapture = Qapture;
11543
11568
  exports.deleteQaDatabase = deleteQaDatabase;
11544
11569
  exports.initQaStudio = initQaStudio;
11545
- //# sourceMappingURL=chunk-5EWNGJ2N.cjs.map
11546
- //# sourceMappingURL=chunk-5EWNGJ2N.cjs.map
11570
+ //# sourceMappingURL=chunk-R6WVHVXO.cjs.map
11571
+ //# sourceMappingURL=chunk-R6WVHVXO.cjs.map