qapture2 0.8.3 → 0.9.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.
@@ -176,6 +176,7 @@ var DEFAULTS = {
176
176
  brandLabel: "Qapture",
177
177
  loginField: { en: "Username", ar: "\u0627\u0633\u0645 \u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645" },
178
178
  rtl: false,
179
+ beta: false,
179
180
  visible: void 0,
180
181
  alwaysVisible: false,
181
182
  hotkey: "shift+alt+q",
@@ -320,9 +321,9 @@ function warnMissingArabic(loginField, credentials, journey, warnings) {
320
321
  if (c.hint !== void 0 && !hasAr(c.hint)) missing.push(`credentials role="${c.role}" (hint.ar)`);
321
322
  }
322
323
  if (!missing.length) return;
323
- const LIMIT = 6;
324
- const shown = missing.slice(0, LIMIT).join("; ");
325
- const rest = missing.length > LIMIT ? ` (+${missing.length - LIMIT} more)` : "";
324
+ const LIMIT2 = 6;
325
+ const shown = missing.slice(0, LIMIT2).join("; ");
326
+ const rest = missing.length > LIMIT2 ? ` (+${missing.length - LIMIT2} more)` : "";
326
327
  warnings.push(
327
328
  `Arabic is used elsewhere in this config, but ${missing.length} bilingual field(s) have no "ar" and will show English to an Arabic-language tester: ${shown}${rest}. Every {en, ar} pair needs BOTH filled in \u2014 a plain string or an {en}-only object reads identically to a bilingual field that was simply never translated.`
328
329
  );
@@ -347,6 +348,7 @@ function validateConfig(input) {
347
348
  journey: [],
348
349
  preamble: null,
349
350
  rtl: DEFAULTS.rtl,
351
+ beta: DEFAULTS.beta,
350
352
  visible: DEFAULTS.visible,
351
353
  alwaysVisible: DEFAULTS.alwaysVisible,
352
354
  hotkey: DEFAULTS.hotkey,
@@ -367,6 +369,7 @@ function validateConfig(input) {
367
369
  journey: [],
368
370
  preamble: null,
369
371
  rtl: DEFAULTS.rtl,
372
+ beta: DEFAULTS.beta,
370
373
  visible: DEFAULTS.visible,
371
374
  alwaysVisible: DEFAULTS.alwaysVisible,
372
375
  hotkey: DEFAULTS.hotkey,
@@ -402,6 +405,7 @@ function validateConfig(input) {
402
405
  const journey = raw["journey"] !== void 0 ? coerceJourney(raw["journey"], warnings) : [];
403
406
  const preamble = raw["preamble"] !== void 0 ? coercePreamble(raw["preamble"]) : null;
404
407
  const rtl = typeof raw["rtl"] === "boolean" ? raw["rtl"] : DEFAULTS.rtl;
408
+ const beta = typeof raw["beta"] === "boolean" ? raw["beta"] : DEFAULTS.beta;
405
409
  const alwaysVisible = typeof raw["alwaysVisible"] === "boolean" ? raw["alwaysVisible"] : DEFAULTS.alwaysVisible;
406
410
  const hotkey = isNonEmptyString(raw["hotkey"]) ? raw["hotkey"].trim() : DEFAULTS.hotkey;
407
411
  const captureHotkey = isNonEmptyString(raw["captureHotkey"]) ? raw["captureHotkey"].trim() : DEFAULTS.captureHotkey;
@@ -424,6 +428,7 @@ function validateConfig(input) {
424
428
  journey,
425
429
  preamble,
426
430
  rtl,
431
+ beta,
427
432
  visible,
428
433
  alwaysVisible,
429
434
  hotkey,
@@ -1735,6 +1740,32 @@ var STR = {
1735
1740
  too_heavy: "This page is too big to redraw \u2014 the tab would freeze. Photograph it instead: one frame, and maps and charts come out right.",
1736
1741
  no_shot: "no screenshot (location saved)",
1737
1742
  annotate_placeholder: "What do you want to do here? (add / remove / change\u2026)",
1743
+ // Asked as two plain questions, never as jargon. "Expected behaviour" is
1744
+ // a phrase from a bug tracker; "what should have happened" is a question
1745
+ // anybody can answer.
1746
+ q_observed: "What happened?",
1747
+ q_observed_hint: "What you saw. Plain words are fine.",
1748
+ q_wanted: "What should have happened?",
1749
+ q_wanted_hint: "What you expected instead. This is the important one.",
1750
+ q_why: "Why does it matter? (optional)",
1751
+ q_why_hint: "What you were trying to get done.",
1752
+ q_fix: "Suggested fix (optional)",
1753
+ q_fix_hint: "Passed on as a suggestion, not an instruction.",
1754
+ voice_start: "Speak",
1755
+ voice_stop: "Stop",
1756
+ voice_failed: "Dictation did not start",
1757
+ voice_denied: "Microphone blocked",
1758
+ dev_mode_label: "Developer mode",
1759
+ diag_title: "Diagnostics",
1760
+ diag_hint: "Checks the handful of things that actually stop screenshots working on a page. Run it if something looks wrong.",
1761
+ diag_run: "Check this page",
1762
+ diag_running: "Checking\u2026",
1763
+ diag_version: "Version {v}",
1764
+ diag_outdated: "Update available: {v} \u2014",
1765
+ diag_current: "up to date",
1766
+ diag_faults: "Recorded problems: {n}",
1767
+ dup_notice: "You already said something like this at {when}:",
1768
+ dev_mode_hint: "Adds severity, the element selector, a suggested-fix field and the source location. Off by default so the person reporting a problem is asked only what they can actually answer.",
1738
1769
  save_point: "Save point",
1739
1770
  save_next: "Save + next",
1740
1771
  save_next_hint: "Save this one and mark up another part of the same screenshot \u2014 no second permission prompt.",
@@ -1950,6 +1981,29 @@ var STR = {
1950
1981
  too_heavy: "\u0647\u0630\u0647 \u0627\u0644\u0635\u0641\u062D\u0629 \u0623\u0643\u0628\u0631 \u0645\u0646 \u0623\u0646 \u064A\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627 \u2014 \u0633\u064A\u062A\u062C\u0645\u0651\u062F \u0627\u0644\u062A\u0628\u0648\u064A\u0628. \u0635\u0648\u0651\u0631\u0647\u0627 \u0628\u062F\u0644 \u0630\u0644\u0643: \u0625\u0637\u0627\u0631 \u0648\u0627\u062D\u062F\u060C \u0648\u062A\u0638\u0647\u0631 \u0627\u0644\u062E\u0631\u0627\u0626\u0637 \u0648\u0627\u0644\u0631\u0633\u0648\u0645 \u0643\u0645\u0627 \u0647\u064A.",
1951
1982
  no_shot: "\u0628\u062F\u0648\u0646 \u0635\u0648\u0631\u0629 (\u062A\u0645 \u062D\u0641\u0638 \u0627\u0644\u0645\u0648\u0642\u0639)",
1952
1983
  annotate_placeholder: "\u0645\u0627\u0630\u0627 \u062A\u0631\u064A\u062F \u0623\u0646 \u062A\u0641\u0639\u0644 \u0647\u0646\u0627\u061F (\u0625\u0636\u0627\u0641\u0629 / \u062D\u0630\u0641 / \u062A\u063A\u064A\u064A\u0631\u2026)",
1984
+ q_observed: "\u0645\u0627 \u0627\u0644\u0630\u064A \u062D\u062F\u062B\u061F",
1985
+ q_observed_hint: "\u0645\u0627 \u0631\u0623\u064A\u062A\u0647. \u0628\u0643\u0644\u0645\u0627\u062A\u0643 \u0627\u0644\u0639\u0627\u062F\u064A\u0629.",
1986
+ q_wanted: "\u0645\u0627 \u0627\u0644\u0630\u064A \u0643\u0627\u0646 \u064A\u062C\u0628 \u0623\u0646 \u064A\u062D\u062F\u062B\u061F",
1987
+ q_wanted_hint: "\u0645\u0627 \u062A\u0648\u0642\u0651\u0639\u062A\u0647 \u0628\u062F\u0644 \u0630\u0644\u0643. \u0647\u0630\u0627 \u0647\u0648 \u0627\u0644\u0623\u0647\u0645.",
1988
+ q_why: "\u0644\u0645\u0627\u0630\u0627 \u064A\u0647\u0645\u0651\u0643\u061F (\u0627\u062E\u062A\u064A\u0627\u0631\u064A)",
1989
+ q_why_hint: "\u0645\u0627 \u0627\u0644\u0630\u064A \u0643\u0646\u062A \u062A\u062D\u0627\u0648\u0644 \u0625\u0646\u062C\u0627\u0632\u0647.",
1990
+ q_fix: "\u0627\u0642\u062A\u0631\u0627\u062D \u0644\u0644\u062D\u0644 (\u0627\u062E\u062A\u064A\u0627\u0631\u064A)",
1991
+ q_fix_hint: "\u064A\u064F\u0645\u0631\u064E\u0651\u0631 \u0643\u0627\u0642\u062A\u0631\u0627\u062D\u060C \u0644\u0627 \u0643\u0623\u0645\u0631.",
1992
+ voice_start: "\u062A\u062D\u062F\u0651\u062B",
1993
+ voice_stop: "\u0625\u064A\u0642\u0627\u0641",
1994
+ voice_failed: "\u0644\u0645 \u064A\u0628\u062F\u0623 \u0627\u0644\u0625\u0645\u0644\u0627\u0621 \u0627\u0644\u0635\u0648\u062A\u064A",
1995
+ voice_denied: "\u0627\u0644\u0645\u064A\u0643\u0631\u0648\u0641\u0648\u0646 \u0645\u062D\u062C\u0648\u0628",
1996
+ dev_mode_label: "\u0648\u0636\u0639 \u0627\u0644\u0645\u0637\u0648\u0651\u0631",
1997
+ diag_title: "\u0627\u0644\u062A\u0634\u062E\u064A\u0635",
1998
+ diag_hint: "\u064A\u0641\u062D\u0635 \u0627\u0644\u0623\u0634\u064A\u0627\u0621 \u0627\u0644\u0642\u0644\u064A\u0644\u0629 \u0627\u0644\u062A\u064A \u062A\u0645\u0646\u0639 \u0639\u0645\u0644 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0641\u0639\u0644\u064A\u064B\u0627 \u0641\u064A \u0627\u0644\u0635\u0641\u062D\u0629. \u0634\u063A\u0651\u0644\u0647 \u0625\u0630\u0627 \u0628\u062F\u0627 \u0634\u064A\u0621 \u063A\u064A\u0631 \u0633\u0644\u064A\u0645.",
1999
+ diag_run: "\u0627\u0641\u062D\u0635 \u0647\u0630\u0647 \u0627\u0644\u0635\u0641\u062D\u0629",
2000
+ diag_running: "\u062C\u0627\u0631\u064D \u0627\u0644\u0641\u062D\u0635\u2026",
2001
+ diag_version: "\u0627\u0644\u0625\u0635\u062F\u0627\u0631 {v}",
2002
+ diag_outdated: "\u064A\u062A\u0648\u0641\u0631 \u062A\u062D\u062F\u064A\u062B: {v} \u2014",
2003
+ diag_current: "\u0645\u062D\u062F\u0651\u062B",
2004
+ diag_faults: "\u0645\u0634\u0643\u0644\u0627\u062A \u0645\u064F\u0633\u062C\u0651\u0644\u0629: {n}",
2005
+ dup_notice: "\u0633\u0628\u0642 \u0623\u0646 \u0643\u062A\u0628\u062A \u0634\u064A\u0626\u064B\u0627 \u0645\u0634\u0627\u0628\u0647\u064B\u0627 \u0627\u0644\u0633\u0627\u0639\u0629 {when}:",
2006
+ dev_mode_hint: "\u064A\u0636\u064A\u0641 \u0627\u0644\u0623\u0647\u0645\u064A\u0629 \u0648\u0645\u064F\u062D\u062F\u0650\u0651\u062F \u0627\u0644\u0639\u0646\u0635\u0631 \u0648\u062D\u0642\u0644 \u0627\u0642\u062A\u0631\u0627\u062D \u0627\u0644\u062D\u0644 \u0648\u0645\u0648\u0642\u0639 \u0627\u0644\u0634\u064A\u0641\u0631\u0629. \u0645\u064F\u0637\u0641\u0623 \u0627\u0641\u062A\u0631\u0627\u0636\u064A\u064B\u0627 \u062D\u062A\u0649 \u0644\u0627 \u064A\u064F\u0633\u0623\u0644 \u0645\u064E\u0646 \u064A\u064F\u0628\u0644\u0651\u063A \u0639\u0646 \u0645\u0634\u0643\u0644\u0629 \u0625\u0644\u0627 \u0639\u0645\u0651\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0627\u0644\u0625\u062C\u0627\u0628\u0629 \u0639\u0646\u0647.",
1953
2007
  save_point: "\u062D\u0641\u0638 \u0627\u0644\u0646\u0642\u0637\u0629",
1954
2008
  save_next: "\u062D\u0641\u0638 + \u0627\u0644\u062A\u0627\u0644\u064A",
1955
2009
  save_next_hint: "\u0627\u062D\u0641\u0638 \u0647\u0630\u0647 \u0648\u062D\u062F\u0651\u062F \u062C\u0632\u0621\u064B\u0627 \u0622\u062E\u0631 \u0645\u0646 \u0646\u0641\u0633 \u0627\u0644\u0644\u0642\u0637\u0629 \u2014 \u0628\u062F\u0648\u0646 \u0637\u0644\u0628 \u0625\u0630\u0646 \u062C\u062F\u064A\u062F.",
@@ -2827,6 +2881,38 @@ function neutralizeDocumentColors(doc, aggressive = false) {
2827
2881
  return touched;
2828
2882
  }
2829
2883
 
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
+
2830
2916
  // src/lib/capture.ts
2831
2917
  var HTML2CANVAS_TIMEOUT_MS = 1e4;
2832
2918
  var CHUNK_TIMEOUT_MS = 8e3;
@@ -3179,12 +3265,13 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3179
3265
  if (blob) return { status: "ok", blob, engine: "exact" };
3180
3266
  }
3181
3267
  } catch (err) {
3182
- console.warn("[QA] cropping the still failed, falling back to DOM render:", err);
3268
+ recordFault("screenshot/crop", err);
3183
3269
  }
3184
3270
  if (prefer === "exact") return { status: "failed" };
3185
3271
  }
3186
3272
  const weight = documentWeight();
3187
3273
  if (weight > TOO_HEAVY_NODES) {
3274
+ recordFault("screenshot/too-heavy", `page has ${weight} elements; the redraw engine was refused`);
3188
3275
  return { status: "too-heavy", nodes: weight };
3189
3276
  }
3190
3277
  try {
@@ -3193,14 +3280,14 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3193
3280
  const blob = await encodeShot(canvas);
3194
3281
  return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
3195
3282
  } catch (err) {
3196
- console.warn("[QA] region capture failed, retrying without gradients/shadows:", err);
3283
+ recordFault("screenshot/render", err);
3197
3284
  try {
3198
3285
  const canvas = await captureViaDom(rect, sx, sy, true);
3199
3286
  if (!canvas) return { status: "failed" };
3200
3287
  const blob = await encodeShot(canvas);
3201
3288
  return blob ? { status: "ok", blob, engine: "dom" } : { status: "failed" };
3202
3289
  } catch (retryErr) {
3203
- console.warn("[QA] region capture failed after retry:", retryErr);
3290
+ recordFault("screenshot/render-retry", retryErr);
3204
3291
  return { status: "failed" };
3205
3292
  }
3206
3293
  }
@@ -3244,10 +3331,32 @@ function formatEvent(ev, t0) {
3244
3331
  }
3245
3332
  return `[${rel}] uncaught: ${oneLine(ev.message)}`;
3246
3333
  }
3334
+ var EVIDENCE_MARK = "<!--qa:evidence-->";
3335
+ function noteContextMarkdown(note, index) {
3336
+ const body = noteToMarkdown(note, { index, keepEvidenceMark: true });
3337
+ const at = body.indexOf(EVIDENCE_MARK);
3338
+ const header = [
3339
+ `# Point ${index} \u2014 runtime context`,
3340
+ "",
3341
+ `Page: ${oneLine(note.route) || "/"}`,
3342
+ `Captured: ${oneLine(note.timestamp)}`,
3343
+ "",
3344
+ "Everything the browser recorded around this capture. Kept out of",
3345
+ "`notes.md` on purpose -- it is here when it is needed, and out of the way",
3346
+ "when it is not.",
3347
+ "",
3348
+ "---",
3349
+ ""
3350
+ ].join("\n");
3351
+ return at === -1 ? `${header}_(nothing was recorded)_
3352
+ ` : header + body.slice(at + EVIDENCE_MARK.length).trimStart();
3353
+ }
3247
3354
  function noteCheckLine(note, index) {
3248
3355
  const where = oneLine(note.route) || "/";
3249
- const what = oneLine(note.description) || "(no description)";
3250
- const trimmed = what.length > 160 ? `${what.slice(0, 157)}...` : what;
3356
+ const wanted = oneLine(note.wanted);
3357
+ const seen = oneLine(note.description) || "(not described)";
3358
+ const claim = wanted || `${seen} \u2014 _no expectation was given; ask before assuming one_`;
3359
+ const trimmed = claim.length > 180 ? `${claim.slice(0, 177)}...` : claim;
3251
3360
  return `- [ ] **check-${index}** (\`${where}\`) \u2014 ${trimmed}`;
3252
3361
  }
3253
3362
  function noteToMarkdown(note, opts) {
@@ -3277,8 +3386,15 @@ function noteToMarkdown(note, opts) {
3277
3386
  );
3278
3387
  }
3279
3388
  }
3389
+ if (note.origin?.component) lines.push(`- **Component:** \`${oneLine(note.origin.component)}\``);
3390
+ if (note.origin?.file) {
3391
+ lines.push(`- **Source:** \`${oneLine(note.origin.file)}${note.origin.line ? `:${note.origin.line}` : ""}\``);
3392
+ }
3280
3393
  if (idx != null && note.screenshot) {
3281
3394
  lines.push(`- **Screenshot:** screenshots/point-${idx}.${shotExtension(note.screenshot)}`);
3395
+ if (note.shotEngine === "dom") {
3396
+ lines.push("- **Screenshot caveat:** this is a re-drawing of the page, not a photograph. Canvas, WebGL and cross-origin images may be blank or missing in it. Trust the words over the picture where they disagree.");
3397
+ }
3282
3398
  }
3283
3399
  if (idx != null && note.afterScreenshot) {
3284
3400
  lines.push(
@@ -3286,7 +3402,29 @@ function noteToMarkdown(note, opts) {
3286
3402
  );
3287
3403
  }
3288
3404
  lines.push("");
3289
- lines.push(oneLine(note.description) ? note.description.trim() : "_(no description)_");
3405
+ lines.push("### Observed");
3406
+ lines.push("");
3407
+ 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
+ );
3414
+ if (note.why && oneLine(note.why)) {
3415
+ lines.push("");
3416
+ lines.push("### Why it matters");
3417
+ lines.push("");
3418
+ lines.push(note.why.trim());
3419
+ }
3420
+ if (note.fixHint && oneLine(note.fixHint)) {
3421
+ lines.push("");
3422
+ lines.push("### Suggested fix (a suggestion, not an instruction)");
3423
+ lines.push("");
3424
+ lines.push(note.fixHint.trim());
3425
+ lines.push("");
3426
+ lines.push("> Weigh this. It came from a person looking at the symptom, not at the code, and following a wrong suggestion costs more than ignoring it.");
3427
+ }
3290
3428
  if (note.followUp && oneLine(note.followUp)) {
3291
3429
  lines.push("");
3292
3430
  lines.push(
@@ -3297,6 +3435,15 @@ function noteToMarkdown(note, opts) {
3297
3435
  lines.push("");
3298
3436
  lines.push(note.followUp.trim());
3299
3437
  }
3438
+ if (idx != null) {
3439
+ lines.push("");
3440
+ lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3441
+ lines.push("");
3442
+ lines.push(`The tester will be taken back to \`${oneLine(note.route) || "/"}\`` + (target?.selector ? `, shown \`${oneLine(target.selector)}\`` : ", shown this region") + ", and asked: *is this now what I asked for?*");
3443
+ lines.push("");
3444
+ lines.push(`Treat it as done only when the paragraph above is true on that page, on a fresh load, without the tester doing anything extra. Record the outcome against \`check-${idx}\` in \`verify.md\`.`);
3445
+ }
3446
+ lines.push(EVIDENCE_MARK);
3300
3447
  const recordedSteps = note.context?.steps ?? [];
3301
3448
  if (recordedSteps.length) {
3302
3449
  const t0 = Date.parse(note.timestamp) || recordedSteps[recordedSteps.length - 1].t;
@@ -3307,14 +3454,6 @@ function noteToMarkdown(note, opts) {
3307
3454
  lines.push(`${i + 1}. ${formatStep(step, t0)}`);
3308
3455
  });
3309
3456
  }
3310
- if (idx != null) {
3311
- lines.push("");
3312
- lines.push(`**Check ${idx} \u2014 how this will be graded**`);
3313
- lines.push("");
3314
- lines.push(`The tester will be taken back to \`${oneLine(note.route) || "/"}\`` + (target?.selector ? `, shown \`${oneLine(target.selector)}\`` : ", shown this region") + ", and asked: *is this now what I asked for?*");
3315
- lines.push("");
3316
- lines.push(`Treat it as done only when the paragraph above is true on that page, on a fresh load, without the tester doing anything extra. Record the outcome against \`check-${idx}\` in \`verify.md\`.`);
3317
- }
3318
3457
  const ctx = note.context;
3319
3458
  if (ctx) {
3320
3459
  const env = ctx.env;
@@ -3361,7 +3500,67 @@ function noteToMarkdown(note, opts) {
3361
3500
  lines.push("");
3362
3501
  lines.push("</details>");
3363
3502
  }
3364
- return lines.join("\n");
3503
+ const whole = lines.join("\n");
3504
+ if (opts?.keepEvidenceMark) return whole;
3505
+ if (!opts?.contextFile) return whole.replace(`${EVIDENCE_MARK}
3506
+ `, "").replace(EVIDENCE_MARK, "");
3507
+ const at = whole.indexOf(EVIDENCE_MARK);
3508
+ const report = at === -1 ? whole : whole.slice(0, at).trimEnd();
3509
+ return `${report}
3510
+
3511
+ <sub>Steps, console, network, environment and element forensics: \`${opts.contextFile}\` \u2014 open it only if the words above leave something genuinely unresolved.</sub>`;
3512
+ }
3513
+
3514
+ // src/lib/reproSpec.ts
3515
+ function lit(value) {
3516
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\r?\n/g, " ");
3517
+ }
3518
+ function line(value, limit = 200) {
3519
+ const one = (value ?? "").replace(/\s+/g, " ").trim();
3520
+ if (!one) return "";
3521
+ return one.length > limit ? `${one.slice(0, limit - 3)}...` : one;
3522
+ }
3523
+ function reproSpec(note, index) {
3524
+ const selector = note.target?.selector;
3525
+ const route = note.route || "/";
3526
+ if (!selector && !note.route) return null;
3527
+ const observed = line(note.description);
3528
+ const expected = line(note.wanted);
3529
+ const out = [];
3530
+ out.push(`// check-${index} -- DRAFT. Delete this file if the change is cosmetic.`);
3531
+ out.push("//");
3532
+ out.push("// The URL and the selector below were captured from the live page, so");
3533
+ out.push("// they are the tedious part already done. The assertion is yours: only");
3534
+ out.push("// you know whether this point is worth pinning down with a test.");
3535
+ out.push("//");
3536
+ if (observed) out.push(`// Observed: ${observed}`);
3537
+ if (expected) out.push(`// Expected: ${expected}`);
3538
+ else out.push("// Expected: (the tester did not say -- ask before asserting anything)");
3539
+ if (note.origin?.file) {
3540
+ out.push(`// Rendered by: ${note.origin.component ?? "?"} (${note.origin.file}${note.origin.line ? `:${note.origin.line}` : ""})`);
3541
+ }
3542
+ out.push("");
3543
+ out.push("import { test, expect } from '@playwright/test';");
3544
+ out.push("");
3545
+ out.push(`test('check-${index}: ${lit(line(note.description, 60) || "reported point")}', async ({ page }) => {`);
3546
+ out.push(` await page.goto('${lit(route)}');`);
3547
+ if (selector) {
3548
+ out.push("");
3549
+ out.push(` const target = page.locator('${lit(selector)}');`);
3550
+ out.push(" await expect(target).toBeVisible();");
3551
+ out.push("");
3552
+ out.push(" // TODO: assert the EXPECTED behaviour quoted above.");
3553
+ out.push(" // Being visible only proves the element is still there -- it does not");
3554
+ out.push(" // prove the thing the tester asked for actually happened.");
3555
+ } else {
3556
+ out.push("");
3557
+ out.push(" // No element was picked for this point -- it was a region or a plain");
3558
+ out.push(" // note. Drive the page to the state described above, then assert.");
3559
+ out.push(" // TODO");
3560
+ }
3561
+ out.push("});");
3562
+ out.push("");
3563
+ return out.join("\n");
3365
3564
  }
3366
3565
 
3367
3566
  // src/lib/exportZip.ts
@@ -3497,7 +3696,15 @@ This archive is not a list of suggestions. Every point in \`notes.md\` is an acc
3497
3696
 
3498
3697
  **What to do.** Do the work, then fill in \`verify.md\`: tick a box only when the check is true on a fresh load of the page named beside it, with the tester doing nothing extra. Where you could not do something, or you think it is the wrong thing to do, leave the box unticked and write one line saying why. An unticked box with a reason is a good answer. A ticked box that does not hold up is the only bad one \u2014 it costs the tester the trip to find out.
3499
3698
 
3500
- Hand \`verify.md\` back with the work.`
3699
+ Hand \`verify.md\` back with the work.
3700
+
3701
+ ### What is in this archive
3702
+
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**.
3704
+ - \`verify.md\` \u2014 the checklist, one unticked box per point.
3705
+ - \`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
+ - \`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.
3707
+ - \`repro/\` \u2014 Playwright drafts, one per point, all optional. Finish the ones where the point is behavioural and worth pinning down; delete the ones where it is cosmetic. That judgement is yours.`
3501
3708
  );
3502
3709
  const stack = typeof p.stack === "string" && p.stack.trim() ? p.stack.trim() : "(not provided)";
3503
3710
  const runArr = toStrings(p.runCommands);
@@ -3637,7 +3844,14 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3637
3844
  ""
3638
3845
  ].join("\n");
3639
3846
  const noteBlocks = notes.map(
3640
- (n, i) => noteToMarkdown(n, { brand: brandLabel, index: i + 1 })
3847
+ (n, i) => noteToMarkdown(n, {
3848
+ brand: brandLabel,
3849
+ index: i + 1,
3850
+ // Runtime evidence goes to its own file and is pointed at from here.
3851
+ // See the contextFile branch in noteMarkdown.ts for why: a long report
3852
+ // measurably lowers an agent's chance of fixing the thing.
3853
+ contextFile: n.context ? `context/point-${i + 1}.md` : void 0
3854
+ })
3641
3855
  );
3642
3856
  const notesBody = noteBlocks.length > 0 ? `${noteBlocks.join("\n\n---\n\n")}
3643
3857
 
@@ -3678,6 +3892,43 @@ async function buildZipBlob(notes, stamp, config, guideChecked, guideSkipped) {
3678
3892
  "Re-open the walkthrough on the tester's machine with `?qa=walk:verify`.",
3679
3893
  ""
3680
3894
  ].join("\n"));
3895
+ const contextDir = zip.folder("context");
3896
+ notes.forEach((n, i) => {
3897
+ if (n.context && contextDir) {
3898
+ contextDir.file(`point-${i + 1}.md`, noteContextMarkdown(n, i + 1));
3899
+ }
3900
+ });
3901
+ const reproDir = zip.folder("repro");
3902
+ let reproCount = 0;
3903
+ notes.forEach((n, i) => {
3904
+ const spec = reproSpec(n, i + 1);
3905
+ if (spec && reproDir) {
3906
+ reproDir.file(`check-${i + 1}.spec.ts`, spec);
3907
+ reproCount++;
3908
+ }
3909
+ });
3910
+ if (reproCount && reproDir) {
3911
+ reproDir.file("README.md", [
3912
+ "# Reproduction drafts",
3913
+ "",
3914
+ "One per point, and every one of them is optional.",
3915
+ "",
3916
+ "**Use one** where the point is behavioural and worth pinning down, so it",
3917
+ "cannot quietly come back later. An executable check is worth far more to",
3918
+ "you than another paragraph of steps written in English.",
3919
+ "",
3920
+ "**Delete it** where the point is cosmetic -- a colour, a spacing, a word.",
3921
+ "A test asserting that a heading is visible proves nothing anybody wanted",
3922
+ "proved, and it is one more file to maintain forever.",
3923
+ "",
3924
+ "That call is yours. The tester was not asked to make it and could not.",
3925
+ "",
3926
+ "What is already done for you in each file: the URL, a selector verified",
3927
+ "against the live DOM at capture time, and the observed and expected",
3928
+ "behaviour quoted in place. What is left is the assertion, marked TODO.",
3929
+ ""
3930
+ ].join("\n"));
3931
+ }
3681
3932
  notes.forEach((n, i) => {
3682
3933
  if (n.screenshot && shots) {
3683
3934
  shots.file(`point-${i + 1}.${shotExtension(n.screenshot)}`, n.screenshot);
@@ -4147,7 +4398,7 @@ function reportMarkdownText(allNotes) {
4147
4398
  "",
4148
4399
  "---",
4149
4400
  ""
4150
- ].filter((line) => line !== "").join("\n");
4401
+ ].filter((line2) => line2 !== "").join("\n");
4151
4402
  const body = ordered.map((n) => noteMarkdownForDisk(n, noteIndex[n.id])).join("\n---\n\n");
4152
4403
  return `${header}
4153
4404
  ${body}`;
@@ -4322,6 +4573,7 @@ function exactShotsWanted(store) {
4322
4573
  }
4323
4574
  var SIMPLE_MODE_KEY = "simpleMode";
4324
4575
  var COMPACT_KEY = "compactCapture";
4576
+ var DEV_MODE_KEY = "developerMode";
4325
4577
  var LAST_CAMPAIGN_KEY = "lastCampaign";
4326
4578
  var AUTO_BACKUP_KEY = "autoBackup";
4327
4579
  var AUTO_BACKUP_AT_KEY = "autoBackupAt";
@@ -4387,7 +4639,15 @@ function QaProvider({
4387
4639
  const [lang, setLangState] = React.useState(() => {
4388
4640
  const saved = storage.getItem(LANG_KEY);
4389
4641
  if (saved === "ar" || saved === "en") return saved;
4390
- return config.rtl ? "ar" : "en";
4642
+ if (config.rtl) return "ar";
4643
+ try {
4644
+ const html = document.documentElement;
4645
+ if (html.getAttribute("dir") === "rtl") return "ar";
4646
+ if ((html.getAttribute("lang") || "").toLowerCase().startsWith("ar")) return "ar";
4647
+ if ((navigator.language || "").toLowerCase().startsWith("ar")) return "ar";
4648
+ } catch {
4649
+ }
4650
+ return "en";
4391
4651
  });
4392
4652
  const [guideChecked, setGuideChecked] = React.useState(
4393
4653
  () => new Set(storage.getJSON(GUIDE_KEY, []))
@@ -4425,6 +4685,9 @@ function QaProvider({
4425
4685
  const [simpleMode, setSimpleModeState] = React.useState(
4426
4686
  () => storage.getItem(SIMPLE_MODE_KEY) === "1"
4427
4687
  );
4688
+ const [developerMode, setDeveloperModeState] = React.useState(
4689
+ () => storage.getItem(DEV_MODE_KEY) === "1"
4690
+ );
4428
4691
  const [compactCapture, setCompactCaptureState] = React.useState(
4429
4692
  () => storage.getItem(COMPACT_KEY) === "1"
4430
4693
  );
@@ -4754,6 +5017,11 @@ function QaProvider({
4754
5017
  target: input.target ?? void 0,
4755
5018
  severity: input.severity,
4756
5019
  status: input.status,
5020
+ wanted: (input.wanted || "").trim() || void 0,
5021
+ why: (input.why || "").trim() || void 0,
5022
+ fixHint: (input.fixHint || "").trim() || void 0,
5023
+ shotEngine: input.shotEngine,
5024
+ origin: input.origin,
4757
5025
  journeyRef,
4758
5026
  context
4759
5027
  };
@@ -5156,6 +5424,10 @@ function QaProvider({
5156
5424
  setCompactCaptureState(on);
5157
5425
  storage.setItem(COMPACT_KEY, on ? "1" : "0");
5158
5426
  }, [storage]);
5427
+ const setDeveloperMode = React.useCallback((on) => {
5428
+ setDeveloperModeState(on);
5429
+ storage.setItem(DEV_MODE_KEY, on ? "1" : "0");
5430
+ }, [storage]);
5159
5431
  const setFilter = React.useCallback((patch) => {
5160
5432
  setFilterState((prev) => ({ ...prev, ...patch }));
5161
5433
  }, []);
@@ -5495,6 +5767,7 @@ function QaProvider({
5495
5767
  // Config passthrough
5496
5768
  namespace: config.namespace,
5497
5769
  brand: config.brand,
5770
+ beta: config.beta === true,
5498
5771
  loginField: config.loginField,
5499
5772
  credentials: config.credentials,
5500
5773
  // Never the raw config value: an unconfigured project falls back to the
@@ -5603,6 +5876,8 @@ function QaProvider({
5603
5876
  simpleMode,
5604
5877
  setSimpleMode,
5605
5878
  compactCapture,
5879
+ developerMode,
5880
+ setDeveloperMode,
5606
5881
  setCompactCapture,
5607
5882
  exportZip: exportZipFn
5608
5883
  };
@@ -5670,6 +5945,16 @@ var ICONS = {
5670
5945
  Square: [
5671
5946
  ["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }]
5672
5947
  ],
5948
+ Info: [
5949
+ ["circle", { cx: "12", cy: "12", r: "10" }],
5950
+ ["path", { d: "M12 16v-4" }],
5951
+ ["path", { d: "M12 8h.01" }]
5952
+ ],
5953
+ Mic: [
5954
+ ["path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }],
5955
+ ["path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }],
5956
+ ["line", { x1: "12", x2: "12", y1: "19", y2: "22" }]
5957
+ ],
5673
5958
  ImagePlus: [
5674
5959
  ["path", { d: "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7" }],
5675
5960
  ["line", { x1: "16", x2: "22", y1: "5", y2: "5" }],
@@ -5903,7 +6188,7 @@ function clampFabPos(p, w = FAB_SIZE_PX, h = FAB_SIZE_PX) {
5903
6188
  };
5904
6189
  }
5905
6190
  function QaFab() {
5906
- const { isOpen, setIsOpen, notes, captureActive, namespace, t } = useQa();
6191
+ const { isOpen, setIsOpen, notes, captureActive, namespace, beta, t } = useQa();
5907
6192
  const [pos, setPos] = React.useState(() => loadFabPos(namespace));
5908
6193
  const dragRef = React.useRef(null);
5909
6194
  const didDragRef = React.useRef(false);
@@ -6016,6 +6301,15 @@ function QaFab() {
6016
6301
  }
6017
6302
  ),
6018
6303
  /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: isOpen ? "X" : "ClipboardList", size: 24 }),
6304
+ beta && !isOpen && /* @__PURE__ */ jsxRuntime.jsx(
6305
+ "span",
6306
+ {
6307
+ className: "qa-absolute qa-rounded-full qa-text-10 qa-font-bold qa-bg-1 qa-text-mid qa-border qa-border-subtle",
6308
+ style: { bottom: "-6px", left: "50%", transform: "translateX(-50%)", padding: "0 5px", lineHeight: "1.3" },
6309
+ "aria-hidden": "true",
6310
+ children: "beta"
6311
+ }
6312
+ ),
6019
6313
  !isOpen && notes.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(
6020
6314
  "span",
6021
6315
  {
@@ -7709,6 +8003,154 @@ function NoteFilterBar() {
7709
8003
  ) })
7710
8004
  ] });
7711
8005
  }
8006
+
8007
+ // src/lib/doctor.ts
8008
+ var HEAVY_PAGE = 6e3;
8009
+ function canvasIsReadable() {
8010
+ try {
8011
+ const c = document.createElement("canvas");
8012
+ c.width = 1;
8013
+ c.height = 1;
8014
+ const ctx = c.getContext("2d");
8015
+ if (!ctx) return false;
8016
+ ctx.fillStyle = "#000";
8017
+ ctx.fillRect(0, 0, 1, 1);
8018
+ c.toDataURL();
8019
+ return true;
8020
+ } catch {
8021
+ return false;
8022
+ }
8023
+ }
8024
+ function taintingImages() {
8025
+ let count = 0;
8026
+ const here = location.origin;
8027
+ for (const img of Array.from(document.images)) {
8028
+ const src = img.currentSrc || img.src;
8029
+ if (!src || src.startsWith("data:") || src.startsWith("blob:")) continue;
8030
+ try {
8031
+ if (new URL(src, here).origin !== here && !img.crossOrigin) count++;
8032
+ } catch {
8033
+ }
8034
+ }
8035
+ return count;
8036
+ }
8037
+ async function runDoctor() {
8038
+ const out = [];
8039
+ if (typeof document === "undefined") return out;
8040
+ const supported = isExactCaptureSupported();
8041
+ const status = getExactCaptureStatus();
8042
+ out.push(
8043
+ !supported ? {
8044
+ label: "Screenshot engine",
8045
+ verdict: "warn",
8046
+ detail: "This browser cannot photograph the tab, so screenshots are re-drawn. Charts, maps and anything on a canvas may come out blank. Chrome or Edge can photograph."
8047
+ } : status === "live" ? { label: "Screenshot engine", verdict: "ok", detail: "Real photographs. This is the accurate one." } : {
8048
+ label: "Screenshot engine",
8049
+ verdict: "warn",
8050
+ detail: "Photographs are switched off, so screenshots are re-drawn and may not match the page."
8051
+ }
8052
+ );
8053
+ const readable = canvasIsReadable();
8054
+ const risky = taintingImages();
8055
+ out.push(
8056
+ !readable ? {
8057
+ label: "Screenshot encoding",
8058
+ verdict: "bad",
8059
+ detail: "This page cannot turn a drawing into an image at all. Re-drawn screenshots will fail here; use photographs."
8060
+ } : risky > 0 ? {
8061
+ label: "Screenshot encoding",
8062
+ verdict: "info",
8063
+ detail: `${risky} image(s) come from another site without permission to be read. They are skipped and appear blank in a re-drawn screenshot; photographs are unaffected.`
8064
+ } : { label: "Screenshot encoding", verdict: "ok", detail: "Nothing on this page blocks screenshot encoding." }
8065
+ );
8066
+ const nodes = document.getElementsByTagName("*").length;
8067
+ out.push({
8068
+ label: "Page size",
8069
+ verdict: nodes > HEAVY_PAGE ? "warn" : "ok",
8070
+ detail: nodes > HEAVY_PAGE ? `${nodes.toLocaleString()} elements. Too many to re-draw without freezing the tab, so re-drawn screenshots are refused here. Photographs work fine.` : `${nodes.toLocaleString()} elements. Comfortable for either engine.`
8071
+ });
8072
+ try {
8073
+ await import('html2canvas');
8074
+ out.push({ label: "Screenshot library", verdict: "ok", detail: "Loads correctly." });
8075
+ } catch {
8076
+ out.push({
8077
+ label: "Screenshot library",
8078
+ verdict: "bad",
8079
+ detail: "Blocked or unreachable \u2014 often a strict Content-Security-Policy. Re-drawn screenshots cannot work here."
8080
+ });
8081
+ }
8082
+ try {
8083
+ const est = await navigator.storage?.estimate?.();
8084
+ if (est?.quota) {
8085
+ const usedPct = Math.round((est.usage ?? 0) / est.quota * 100);
8086
+ out.push({
8087
+ label: "Storage",
8088
+ verdict: usedPct > 90 ? "bad" : usedPct > 70 ? "warn" : "ok",
8089
+ detail: `${usedPct}% of this site's storage used. Notes are kept in this browser until you export them.`
8090
+ });
8091
+ }
8092
+ } catch {
8093
+ }
8094
+ const voice = typeof window !== "undefined" && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
8095
+ out.push({
8096
+ label: "Voice input",
8097
+ verdict: voice ? "ok" : "info",
8098
+ detail: voice ? "Available \u2014 you can speak your notes." : "Not available in this browser. Typing still works."
8099
+ });
8100
+ out.push({
8101
+ label: "Secure connection",
8102
+ verdict: window.isSecureContext ? "ok" : "bad",
8103
+ detail: window.isSecureContext ? "https \u2014 photographs and voice are allowed." : "Not https. Photographs and voice are blocked by the browser on an insecure page."
8104
+ });
8105
+ return out;
8106
+ }
8107
+
8108
+ // src/lib/versionCheck.ts
8109
+ var CACHE_KEY = "qa.versionCheck";
8110
+ var A_DAY = 24 * 60 * 60 * 1e3;
8111
+ var REGISTRY = "https://registry.npmjs.org/qapture2/latest";
8112
+ function isNewer(latest, current) {
8113
+ const norm = (v) => v.replace(/^[^\d]*/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
8114
+ const a = norm(latest);
8115
+ const b = norm(current);
8116
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
8117
+ const x = a[i] ?? 0;
8118
+ const y = b[i] ?? 0;
8119
+ if (x !== y) return x > y;
8120
+ }
8121
+ return false;
8122
+ }
8123
+ async function latestVersion() {
8124
+ if (typeof window === "undefined") return null;
8125
+ try {
8126
+ const raw = window.localStorage.getItem(CACHE_KEY);
8127
+ if (raw) {
8128
+ const cached = JSON.parse(raw);
8129
+ if (Date.now() - cached.at < A_DAY && cached.latest) return cached.latest;
8130
+ }
8131
+ } catch {
8132
+ }
8133
+ try {
8134
+ const res = await fetch(REGISTRY, { headers: { accept: "application/json" } });
8135
+ if (!res.ok) return null;
8136
+ const body = await res.json();
8137
+ const latest = body.version;
8138
+ if (!latest) return null;
8139
+ try {
8140
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), latest }));
8141
+ } catch {
8142
+ }
8143
+ return latest;
8144
+ } catch {
8145
+ return null;
8146
+ }
8147
+ }
8148
+ function upgradeHint(latest) {
8149
+ return `npm i qapture2@${latest}`;
8150
+ }
8151
+
8152
+ // src/version.ts
8153
+ var QA_VERSION = "0.9.0" ;
7712
8154
  function Section({
7713
8155
  icon,
7714
8156
  title,
@@ -7771,8 +8213,24 @@ function SettingsSheet({ onClose }) {
7771
8213
  setSimpleMode,
7772
8214
  compactCapture,
7773
8215
  setCompactCapture,
8216
+ developerMode,
8217
+ setDeveloperMode,
7774
8218
  notes
7775
8219
  } = useQa();
8220
+ const [checks, setChecks] = React.useState([]);
8221
+ const [running, setRunning] = React.useState(false);
8222
+ const [faults2, setFaults] = React.useState(() => readFaults());
8223
+ const [latest, setLatest] = React.useState(null);
8224
+ React.useEffect(() => {
8225
+ let alive = true;
8226
+ void latestVersion().then((v) => {
8227
+ if (alive) setLatest(v);
8228
+ });
8229
+ setFaults(readFaults());
8230
+ return () => {
8231
+ alive = false;
8232
+ };
8233
+ }, []);
7776
8234
  const [project, setProject] = React.useState(lastCampaign.project);
7777
8235
  const [campaign2, setCampaign] = React.useState(lastCampaign.campaign || suggestCampaignName());
7778
8236
  const [tester, setTester] = React.useState(lastCampaign.tester);
@@ -8047,6 +8505,20 @@ function SettingsSheet({ onClose }) {
8047
8505
  ] }),
8048
8506
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "qa-h-px qa-bg-3 qa-mt-3 qa-mb-4" }),
8049
8507
  /* @__PURE__ */ jsxRuntime.jsxs(Section, { icon: "Settings", title: t("settings"), children: [
8508
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-items-start qa-gap-2 qa-text-xs qa-text-hi", style: { cursor: "pointer" }, children: [
8509
+ /* @__PURE__ */ jsxRuntime.jsx(
8510
+ "input",
8511
+ {
8512
+ type: "checkbox",
8513
+ checked: developerMode,
8514
+ onChange: (e) => setDeveloperMode(e.target.checked)
8515
+ }
8516
+ ),
8517
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
8518
+ t("dev_mode_label"),
8519
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-block qa-text-10 qa-text-mid", children: t("dev_mode_hint") })
8520
+ ] })
8521
+ ] }),
8050
8522
  /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "qa-flex qa-items-center qa-gap-2 qa-text-xs qa-text-hi", style: { cursor: "pointer" }, children: [
8051
8523
  /* @__PURE__ */ jsxRuntime.jsx(
8052
8524
  "input",
@@ -8069,6 +8541,91 @@ function SettingsSheet({ onClose }) {
8069
8541
  ),
8070
8542
  t("compact_mode")
8071
8543
  ] })
8544
+ ] }),
8545
+ /* @__PURE__ */ jsxRuntime.jsxs(Section, { icon: "Info", title: t("diag_title"), children: [
8546
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "qa-text-10 qa-text-mid", children: t("diag_hint") }),
8547
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex qa-items-center qa-gap-2 qa-flex-wrap", children: [
8548
+ /* @__PURE__ */ jsxRuntime.jsxs(
8549
+ "button",
8550
+ {
8551
+ type: "button",
8552
+ onClick: () => void (async () => {
8553
+ setRunning(true);
8554
+ try {
8555
+ setChecks(await runDoctor());
8556
+ } finally {
8557
+ setRunning(false);
8558
+ }
8559
+ })(),
8560
+ disabled: running,
8561
+ "data-qa-doctor": "true",
8562
+ className: "qa-tap qa-inline-flex qa-items-center qa-gap-1.5 qa-rounded-md qa-border qa-border-subtle qa-px-2 qa-py-1 qa-text-xs qa-text-hi qa-focus-ring",
8563
+ style: { background: "transparent", cursor: "pointer" },
8564
+ children: [
8565
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Check", size: 13 }),
8566
+ running ? t("diag_running") : t("diag_run")
8567
+ ]
8568
+ }
8569
+ ),
8570
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-text-10 qa-text-mid", children: [
8571
+ t("diag_version", { v: QA_VERSION }),
8572
+ latest && isNewer(latest, QA_VERSION) ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8573
+ " \xB7 ",
8574
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-warn", children: t("diag_outdated", { v: latest }) }),
8575
+ /* @__PURE__ */ jsxRuntime.jsx("code", { className: "qa-ms-1", children: upgradeHint(latest) })
8576
+ ] }) : latest ? ` \xB7 ${t("diag_current")}` : ""
8577
+ ] })
8578
+ ] }),
8579
+ checks.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "qa-space-y-1", "data-qa-doctor-results": "true", children: checks.map((c) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "qa-text-11 qa-text-hi", children: [
8580
+ /* @__PURE__ */ jsxRuntime.jsx(
8581
+ "span",
8582
+ {
8583
+ "aria-hidden": true,
8584
+ className: c.verdict === "ok" ? "qa-text-success" : c.verdict === "bad" ? "qa-text-danger" : c.verdict === "warn" ? "qa-text-warn" : "qa-text-mid",
8585
+ children: c.verdict === "ok" ? "\u25CF" : c.verdict === "bad" ? "\u25B2" : c.verdict === "warn" ? "\u25B2" : "\u25CB"
8586
+ }
8587
+ ),
8588
+ " ",
8589
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: c.label }),
8590
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-block qa-text-10 qa-text-mid qa-ms-3", children: c.detail })
8591
+ ] }, c.label)) }),
8592
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex qa-items-center qa-gap-2 qa-flex-wrap", children: [
8593
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-11 qa-text-mid", children: t("diag_faults", { n: faults2.length }) }),
8594
+ faults2.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
8595
+ /* @__PURE__ */ jsxRuntime.jsx(
8596
+ "button",
8597
+ {
8598
+ type: "button",
8599
+ onClick: () => void navigator.clipboard?.writeText(faultsAsText(QA_VERSION)),
8600
+ className: "qa-tap qa-rounded-md qa-border qa-border-subtle qa-px-2 qa-py-0.5 qa-text-10 qa-text-hi",
8601
+ style: { background: "transparent", cursor: "pointer" },
8602
+ children: t("copy_prompt")
8603
+ }
8604
+ ),
8605
+ /* @__PURE__ */ jsxRuntime.jsx(
8606
+ "button",
8607
+ {
8608
+ type: "button",
8609
+ onClick: () => {
8610
+ clearFaults();
8611
+ setFaults([]);
8612
+ },
8613
+ className: "qa-tap qa-rounded-md qa-border qa-border-subtle qa-px-2 qa-py-0.5 qa-text-10 qa-text-mid",
8614
+ style: { background: "transparent", cursor: "pointer" },
8615
+ children: t("clear_all")
8616
+ }
8617
+ )
8618
+ ] })
8619
+ ] }),
8620
+ faults2.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "qa-space-y-0.5", children: faults2.slice(0, 5).map((f, i) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "qa-text-10 qa-text-mid", children: [
8621
+ /* @__PURE__ */ jsxRuntime.jsxs("code", { children: [
8622
+ "[",
8623
+ f.where,
8624
+ "]"
8625
+ ] }),
8626
+ " ",
8627
+ f.what
8628
+ ] }, `${f.at}-${i}`)) })
8072
8629
  ] })
8073
8630
  ] })
8074
8631
  ]
@@ -8403,17 +8960,17 @@ function QaPanel() {
8403
8960
  if (typeof window === "undefined") return 0;
8404
8961
  const vv = window.visualViewport;
8405
8962
  if (!vv) return 0;
8406
- const overlap = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
8407
- if (overlap <= KEYBOARD_OVERLAP_THRESHOLD) return 0;
8963
+ const overlap2 = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
8964
+ if (overlap2 <= KEYBOARD_OVERLAP_THRESHOLD) return 0;
8408
8965
  const panel = panelRef.current;
8409
8966
  if (!panel) return 0;
8410
8967
  const root2 = panel.getRootNode();
8411
8968
  const active = root2.activeElement;
8412
8969
  if (!active || !panel.contains(active)) return 0;
8413
8970
  const tag = active.tagName;
8414
- if (tag === "TEXTAREA") return Math.round(overlap) + KEYBOARD_LIFT_GAP;
8971
+ if (tag === "TEXTAREA") return Math.round(overlap2) + KEYBOARD_LIFT_GAP;
8415
8972
  if (tag === "INPUT" && !NON_TEXT_INPUT_TYPES.has(active.type)) {
8416
- return Math.round(overlap) + KEYBOARD_LIFT_GAP;
8973
+ return Math.round(overlap2) + KEYBOARD_LIFT_GAP;
8417
8974
  }
8418
8975
  return 0;
8419
8976
  } catch {
@@ -9353,6 +9910,209 @@ function unlockPageScroll() {
9353
9910
  window.removeEventListener("touchmove", preventScroll, LISTENER_OPTIONS);
9354
9911
  }
9355
9912
  }
9913
+
9914
+ // src/lib/origin.ts
9915
+ function fiberOf(el) {
9916
+ for (const key in el) {
9917
+ if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
9918
+ return el[key] ?? null;
9919
+ }
9920
+ }
9921
+ return null;
9922
+ }
9923
+ function nameOf(fiber) {
9924
+ const type = fiber.type;
9925
+ if (!type || typeof type === "string") return null;
9926
+ const direct = type.displayName || type.name;
9927
+ if (direct) return direct;
9928
+ const inner = type.render?.displayName || type.render?.name;
9929
+ return inner || null;
9930
+ }
9931
+ function tidyPath(file) {
9932
+ const marks = ["/src/", "/app/", "/components/", "/pages/", "/lib/"];
9933
+ for (const mark of marks) {
9934
+ const at = file.lastIndexOf(mark);
9935
+ if (at !== -1) return file.slice(at + 1);
9936
+ }
9937
+ return file.split("/").slice(-2).join("/");
9938
+ }
9939
+ var MAX_WALK = 30;
9940
+ function resolveOrigin(el) {
9941
+ if (!el || typeof window === "undefined") return void 0;
9942
+ let fiber;
9943
+ try {
9944
+ fiber = fiberOf(el);
9945
+ } catch {
9946
+ return void 0;
9947
+ }
9948
+ if (!fiber) return void 0;
9949
+ const out = {};
9950
+ let hops = 0;
9951
+ for (let f = fiber; f && hops < MAX_WALK; f = f.return ?? null, hops++) {
9952
+ if (!out.component) {
9953
+ const name = nameOf(f);
9954
+ if (name) out.component = name;
9955
+ }
9956
+ if (!out.file) {
9957
+ const src = f._debugSource;
9958
+ if (src?.fileName) {
9959
+ out.file = tidyPath(src.fileName);
9960
+ if (typeof src.lineNumber === "number") out.line = src.lineNumber;
9961
+ }
9962
+ }
9963
+ if (out.component && out.file) break;
9964
+ }
9965
+ return out.component || out.file ? out : void 0;
9966
+ }
9967
+
9968
+ // src/lib/duplicate.ts
9969
+ var NOISE = /* @__PURE__ */ new Set([
9970
+ "the",
9971
+ "this",
9972
+ "that",
9973
+ "a",
9974
+ "an",
9975
+ "is",
9976
+ "it",
9977
+ "to",
9978
+ "and",
9979
+ "of",
9980
+ "in",
9981
+ "on",
9982
+ "for",
9983
+ "be",
9984
+ "should",
9985
+ "i",
9986
+ "we",
9987
+ "please",
9988
+ "here",
9989
+ "\u0647\u0630\u0627",
9990
+ "\u0647\u0630\u0647",
9991
+ "\u0641\u064A",
9992
+ "\u0645\u0646",
9993
+ "\u0639\u0644\u0649",
9994
+ "\u0627\u0644\u0649",
9995
+ "\u0625\u0644\u0649",
9996
+ "\u0627\u0646",
9997
+ "\u0623\u0646",
9998
+ "\u0648",
9999
+ "\u0627\u0644"
10000
+ ]);
10001
+ function words(text) {
10002
+ return new Set(
10003
+ (text || "").toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 1 && !NOISE.has(w))
10004
+ );
10005
+ }
10006
+ function overlap(a, b) {
10007
+ if (!a.size || !b.size) return 0;
10008
+ let shared = 0;
10009
+ for (const w of a) if (b.has(w)) shared++;
10010
+ return shared / (a.size + b.size - shared);
10011
+ }
10012
+ var SAME_ENOUGH = 0.7;
10013
+ function findDuplicate(text, selector, existing) {
10014
+ const typed = words(text);
10015
+ if (typed.size < 3) return null;
10016
+ for (const note of existing) {
10017
+ if (selector && note.target?.selector && note.target.selector !== selector) continue;
10018
+ if (selector && !note.target?.selector) continue;
10019
+ if (!selector && note.target?.selector) continue;
10020
+ if (overlap(typed, words(note.description)) >= SAME_ENOUGH) return note;
10021
+ }
10022
+ return null;
10023
+ }
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
+ }
9356
10116
  var DRAG_THRESHOLD2 = 6;
9357
10117
  var TOUCH_DRAG_THRESHOLD = 12;
9358
10118
  var MIN_REGION_SIZE = 8;
@@ -9407,6 +10167,8 @@ function CaptureMode() {
9407
10167
  setCompactCapture,
9408
10168
  exactShots,
9409
10169
  photographNow,
10170
+ developerMode,
10171
+ notes,
9410
10172
  capturePrefill
9411
10173
  } = useQa();
9412
10174
  const coarse = useCoarsePointer();
@@ -9422,12 +10184,21 @@ function CaptureMode() {
9422
10184
  const [shot, setShot] = React.useState(null);
9423
10185
  const stillRef = React.useRef(null);
9424
10186
  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
+ const [origin, setOrigin] = React.useState(void 0);
10191
+ const [spoken, setSpoken] = React.useState("");
9425
10192
  const [shotEngine, setShotEngine] = React.useState(null);
9426
10193
  const [shotUrl, setShotUrl] = React.useState(null);
9427
10194
  const [capturing, setCapturing] = React.useState(false);
9428
10195
  const [captureError, setCaptureError] = React.useState(false);
9429
10196
  const [tooHeavy, setTooHeavy] = React.useState(false);
9430
10197
  const [description, setDescription] = React.useState(capturePrefill);
10198
+ const twin = React.useMemo(
10199
+ () => description.trim().length > 8 ? findDuplicate(description, selection?.selector, notes) : null,
10200
+ [description, selection, notes]
10201
+ );
9431
10202
  const [severity, setSeverity] = React.useState("bug");
9432
10203
  const [targetForensics, setTargetForensics] = React.useState(void 0);
9433
10204
  const taRef = React.useRef(null);
@@ -9569,6 +10340,7 @@ function CaptureMode() {
9569
10340
  if (regionRect) {
9570
10341
  const sel2 = { kind: "region", rect: regionRect };
9571
10342
  setTargetForensics(void 0);
10343
+ setOrigin(void 0);
9572
10344
  if (coarse) {
9573
10345
  setCandidate(sel2);
9574
10346
  setPhase("confirming");
@@ -9591,6 +10363,7 @@ function CaptureMode() {
9591
10363
  tagName: el.tagName.toLowerCase()
9592
10364
  };
9593
10365
  setTargetForensics(collectTargetForensics(el));
10366
+ setOrigin(resolveOrigin(el));
9594
10367
  if (coarse) {
9595
10368
  setCandidate(sel);
9596
10369
  setHover({ rect: sel.rect, selector: sel.selector || "" });
@@ -9735,8 +10508,12 @@ function CaptureMode() {
9735
10508
  setShot(null);
9736
10509
  setShotEngine(null);
9737
10510
  setDescription("");
10511
+ setWanted("");
10512
+ setWhy("");
10513
+ setFixHint("");
9738
10514
  setSeverity("bug");
9739
10515
  setTargetForensics(void 0);
10516
+ setOrigin(void 0);
9740
10517
  setCaptureError(false);
9741
10518
  }, []);
9742
10519
  const save = async (keepGoing = false) => {
@@ -9756,9 +10533,14 @@ function CaptureMode() {
9756
10533
  };
9757
10534
  await addNote({
9758
10535
  description,
10536
+ wanted,
10537
+ why,
10538
+ fixHint,
9759
10539
  screenshot: shot ?? void 0,
10540
+ shotEngine: shotEngine ?? void 0,
9760
10541
  target,
9761
10542
  severity,
10543
+ origin,
9762
10544
  forensics: selection.kind === "element" ? targetForensics : void 0
9763
10545
  });
9764
10546
  if (keepGoing) {
@@ -10388,8 +11170,8 @@ function CaptureMode() {
10388
11170
  ]
10389
11171
  }
10390
11172
  ),
10391
- /* @__PURE__ */ jsxRuntime.jsx(LocationReveal, { target: selection }),
10392
- /* @__PURE__ */ jsxRuntime.jsxs(
11173
+ developerMode && /* @__PURE__ */ jsxRuntime.jsx(LocationReveal, { target: selection }),
11174
+ developerMode && /* @__PURE__ */ jsxRuntime.jsxs(
10393
11175
  "div",
10394
11176
  {
10395
11177
  role: "group",
@@ -10419,24 +11201,98 @@ function CaptureMode() {
10419
11201
  ]
10420
11202
  }
10421
11203
  ),
10422
- /* @__PURE__ */ jsxRuntime.jsx(
10423
- "textarea",
10424
- {
10425
- ref: taRef,
10426
- value: description,
10427
- onChange: (e) => setDescription(e.target.value),
10428
- onKeyDown: (e) => {
10429
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void save(e.shiftKey);
10430
- if ((e.altKey || e.metaKey || e.ctrlKey) && SEVERITIES2[Number(e.key) - 1]) {
10431
- e.preventDefault();
10432
- setSeverity(SEVERITIES2[Number(e.key) - 1]);
11204
+ twin && /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-text-10 qa-text-warn", "data-qa-duplicate": "true", children: [
11205
+ t("dup_notice", { when: new Date(twin.timestamp).toLocaleTimeString() }),
11206
+ " \u201C",
11207
+ twin.description.slice(0, 70),
11208
+ twin.description.length > 70 ? "\u2026" : "",
11209
+ "\u201D"
11210
+ ] }),
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
10433
11219
  }
10434
- },
10435
- rows: 3,
10436
- placeholder: t("annotate_placeholder"),
10437
- 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"
10438
- }
10439
- ),
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
+ ] }),
11292
+ developerMode && origin && (origin.component || origin.file) && /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-text-10 qa-text-mid", "data-qa-origin": "true", children: [
11293
+ origin.component ?? "\u2014",
11294
+ origin.file ? ` \xB7 ${origin.file}${origin.line ? `:${origin.line}` : ""}` : ""
11295
+ ] }),
10440
11296
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-flex qa-items-center qa-gap-2", children: [
10441
11297
  /* @__PURE__ */ jsxRuntime.jsxs(
10442
11298
  "button",
@@ -10478,8 +11334,7 @@ function CaptureMode() {
10478
11334
  ] }),
10479
11335
  /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "qa-text-center qa-text-10 qa-text-slate-400", children: [
10480
11336
  t("save_hint"),
10481
- " \xB7 ",
10482
- t("severity_keys")
11337
+ developerMode ? ` \xB7 ${t("severity_keys")}` : ""
10483
11338
  ] })
10484
11339
  ] })
10485
11340
  ]
@@ -10687,5 +11542,5 @@ function Qapture({ config }) {
10687
11542
  exports.Qapture = Qapture;
10688
11543
  exports.deleteQaDatabase = deleteQaDatabase;
10689
11544
  exports.initQaStudio = initQaStudio;
10690
- //# sourceMappingURL=chunk-OSYYMS6R.cjs.map
10691
- //# sourceMappingURL=chunk-OSYYMS6R.cjs.map
11545
+ //# sourceMappingURL=chunk-5EWNGJ2N.cjs.map
11546
+ //# sourceMappingURL=chunk-5EWNGJ2N.cjs.map