qapture2 0.7.2 → 0.7.4

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.
package/README.md CHANGED
@@ -326,10 +326,40 @@ failure with nothing on screen to reveal it.
326
326
 
327
327
  Captures now render the union of the viewport and the selection, so an element
328
328
  that leaves the screen in any direction is rendered whole, up to 4000px per
329
- side (past a four-viewport render budget the scale drops, not the framing).
330
- The exact engine can't photograph off-screen pixels, so there the selection is
331
- trimmed to the visible part instead — cropped, never displaced. `npm run
332
- element-capture-test` measures all three overflow directions.
329
+ side (past a ~64 MB device-pixel render budget the scale drops, not the
330
+ framing). The exact engine can't photograph off-screen pixels, so there the
331
+ selection is trimmed to the visible part instead — cropped, never displaced.
332
+ `npm run element-capture-test` measures all four cases.
333
+
334
+ **v0.7.3 corrected the half of that which was wrong.** Chasing an element's
335
+ whole box is only right when the whole box is *drawn*. Inside a scroll
336
+ container it isn't: a dashboard scrolls an inner `overflow:auto` box, so a
337
+ 3000px column inside a 700px box is 700px of pixels and 2300px of nothing.
338
+ 0.7.2 rendered the nothing — measured at 23.3% element, 76.7% empty page, with
339
+ the real content squeezed from 1040px wide to 312px by the long-edge cap.
340
+
341
+ Captures are now clipped to what the browser actually paints: the element's box
342
+ intersected with every ancestor that clips it, on either axis, up to `<html>`.
343
+ The viewport is deliberately not a clipper, so a long form on an ordinary
344
+ scrolling page still captures in full. Same fixture after: 100% element, full
345
+ 1040×1400. The hover outline is clipped identically, so what you highlight is
346
+ what you get.
347
+
348
+ ### "The screenshot looks simulated"
349
+
350
+ Because by default it is. The DOM engine re-draws your page; fonts shift,
351
+ shadows and gradients flatten, `<canvas>` and video come out blank. From
352
+ v0.7.3 a redrawn capture says so under the preview and offers a one-tap **Use
353
+ real screenshots**, which grants tab-share and re-shoots the same selection
354
+ with the exact engine below.
355
+
356
+ That upgrade is Chromium-only, because it depends on `preferCurrentTab`. On
357
+ Safari and Firefox the redraw is the only engine there is — so from v0.7.4 the
358
+ card says so outright and points you at Chrome, rather than leaving you to
359
+ conclude the tool is broken. **If your app draws anything with inline SVG, a
360
+ `<canvas>`, or video, do your screenshot QA in Chrome.** A redraw will render
361
+ an SVG chart as bare outlines and a canvas as a blank box, every time, in any
362
+ tool built this way.
333
363
 
334
364
  ### `exact` — opt-in, pixel-for-pixel
335
365
 
@@ -1782,6 +1782,9 @@ var STR = {
1782
1782
  expected_label: "Expected",
1783
1783
  exact_label: "Pixel-exact shots",
1784
1784
  exact_hint: "Screenshots become a real photo of the tab instead of a redraw. Asks once to share this tab; nothing leaves your device.",
1785
+ redraw_notice: "This is a redraw of the page, not a real photo.",
1786
+ redraw_action: "Use real screenshots",
1787
+ redraw_no_engine: "This browser cannot photograph a tab, so charts and canvases may come out wrong. Open the app in Chrome, Edge or Brave for true screenshots.",
1785
1788
  exact_on: "Pixel-exact screenshots on",
1786
1789
  exact_off: "Back to redrawn screenshots",
1787
1790
  exact_declined: "Staying on redrawn screenshots",
@@ -1976,6 +1979,9 @@ var STR = {
1976
1979
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1977
1980
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1978
1981
  exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0644\u0644\u062A\u0628\u0648\u064A\u0628 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645\u0647. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1982
+ redraw_notice: "\u0647\u0630\u0647 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645 \u0644\u0644\u0635\u0641\u062D\u0629\u060C \u0648\u0644\u064A\u0633\u062A \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629.",
1983
+ redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1984
+ redraw_no_engine: "\u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u062A\u0628\u0648\u064A\u0628\u060C \u0644\u0630\u0627 \u0642\u062F \u062A\u0638\u0647\u0631 \u0627\u0644\u0645\u062E\u0637\u0637\u0627\u062A \u0648\u0627\u0644\u0644\u0648\u062D\u0627\u062A \u0628\u0634\u0643\u0644 \u062E\u0627\u0637\u0626. \u0627\u0641\u062A\u062D \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0641\u064A Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
1979
1985
  exact_on: "\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u0637\u0627\u0628\u0642\u0629",
1980
1986
  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",
1981
1987
  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",
@@ -2538,7 +2544,7 @@ var MAX_SHOT_EDGE = 1800;
2538
2544
  var WEBP_QUALITY = 0.92;
2539
2545
  var STUCK_ATTR = "data-qa-stuck";
2540
2546
  var MAX_CAPTURE_EDGE = 4e3;
2541
- var MAX_RENDER_VIEWPORTS = 4;
2547
+ var MAX_RENDER_DEVICE_PIXELS = 16e6;
2542
2548
  function withTimeout(promise, ms) {
2543
2549
  let timer;
2544
2550
  return Promise.race([
@@ -2710,6 +2716,39 @@ function viewportSize() {
2710
2716
  vh: document.documentElement.clientHeight || window.innerHeight
2711
2717
  };
2712
2718
  }
2719
+ function clipToPaintedArea(el, rect) {
2720
+ let left = rect.left;
2721
+ let top = rect.top;
2722
+ let right = rect.left + rect.width;
2723
+ let bottom = rect.top + rect.height;
2724
+ let node = el.parentElement;
2725
+ while (node) {
2726
+ let style;
2727
+ try {
2728
+ style = getComputedStyle(node);
2729
+ } catch {
2730
+ break;
2731
+ }
2732
+ const clipsX = style.overflowX !== "visible";
2733
+ const clipsY = style.overflowY !== "visible";
2734
+ if (clipsX || clipsY) {
2735
+ const b = node.getBoundingClientRect();
2736
+ const padLeft = b.left + node.clientLeft;
2737
+ const padTop = b.top + node.clientTop;
2738
+ if (clipsX) {
2739
+ left = Math.max(left, padLeft);
2740
+ right = Math.min(right, padLeft + node.clientWidth);
2741
+ }
2742
+ if (clipsY) {
2743
+ top = Math.max(top, padTop);
2744
+ bottom = Math.min(bottom, padTop + node.clientHeight);
2745
+ }
2746
+ }
2747
+ node = node.parentElement;
2748
+ }
2749
+ if (right - left < 2 || bottom - top < 2) return rect;
2750
+ return { left, top, width: right - left, height: bottom - top };
2751
+ }
2713
2752
  function intersectViewport(rect) {
2714
2753
  const { vw, vh } = viewportSize();
2715
2754
  const left = Math.max(0, rect.left);
@@ -2746,9 +2785,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2746
2785
  const { vw, vh } = viewportSize();
2747
2786
  const plan = planRender(rect, sx, sy, vw, vh);
2748
2787
  let scale = Math.min(window.devicePixelRatio || 1, 2);
2749
- const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2750
2788
  const area = plan.render.width * plan.render.height;
2751
- if (area > budget) scale *= Math.sqrt(budget / area);
2789
+ if (area * scale * scale > MAX_RENDER_DEVICE_PIXELS) {
2790
+ scale = Math.sqrt(MAX_RENDER_DEVICE_PIXELS / area);
2791
+ }
2752
2792
  const marked = markStuckElements();
2753
2793
  try {
2754
2794
  const full = await withTimeout(
@@ -8880,6 +8920,7 @@ function CaptureMode() {
8880
8920
  const [candidate, setCandidate] = React.useState(null);
8881
8921
  const [regionMode, setRegionMode] = React.useState(false);
8882
8922
  const [shot, setShot] = React.useState(null);
8923
+ const [shotEngine, setShotEngine] = React.useState(null);
8883
8924
  const [shotUrl, setShotUrl] = React.useState(null);
8884
8925
  const [capturing, setCapturing] = React.useState(false);
8885
8926
  const [captureError, setCaptureError] = React.useState(false);
@@ -8922,6 +8963,7 @@ function CaptureMode() {
8922
8963
  return;
8923
8964
  }
8924
8965
  setCaptureError(outcome.status === "failed");
8966
+ setShotEngine(outcome.status === "ok" ? outcome.engine : null);
8925
8967
  setShot(blob);
8926
8968
  setShotUrl((old) => {
8927
8969
  if (old) URL.revokeObjectURL(old);
@@ -8986,7 +9028,9 @@ function CaptureMode() {
8986
9028
  }
8987
9029
  const r = el.getBoundingClientRect();
8988
9030
  setHover({
8989
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9031
+ // Highlight only what will actually be captured, so the outline is a
9032
+ // promise the screenshot can keep.
9033
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
8990
9034
  selector: getStableSelector(el)
8991
9035
  });
8992
9036
  }
@@ -9033,7 +9077,10 @@ function CaptureMode() {
9033
9077
  const r = el.getBoundingClientRect();
9034
9078
  const sel = {
9035
9079
  kind: "element",
9036
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9080
+ // What the browser PAINTS of this element, not its full bounding box —
9081
+ // a row inside a scrolled table is only ever drawn where the table shows
9082
+ // it. See clipToPaintedArea().
9083
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
9037
9084
  selector: getStableSelector(el),
9038
9085
  text: (el.innerText ?? el.textContent ?? "").trim().slice(0, 120),
9039
9086
  tagName: el.tagName.toLowerCase()
@@ -9644,7 +9691,7 @@ function CaptureMode() {
9644
9691
  )
9645
9692
  ] }),
9646
9693
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-space-y-2 qa-p-3", children: [
9647
- /* @__PURE__ */ jsxRuntime.jsx(
9694
+ /* @__PURE__ */ jsxRuntime.jsxs(
9648
9695
  "div",
9649
9696
  {
9650
9697
  className: "qa-flex qa-min-h-16 qa-items-center qa-justify-center qa-rounded-lg qa-border",
@@ -9652,66 +9699,113 @@ function CaptureMode() {
9652
9699
  borderColor: "var(--qa-border-subtle)",
9653
9700
  background: "var(--qa-surface-0)"
9654
9701
  },
9655
- children: capturing ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9656
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9657
- t("capturing")
9658
- ] }) : shotUrl ? /* @__PURE__ */ jsxRuntime.jsxs(
9659
- "button",
9660
- {
9661
- type: "button",
9662
- onClick: () => setDrawing(true),
9663
- title: t("draw_label"),
9664
- "aria-label": t("draw_label"),
9665
- className: "qa-relative qa-group qa-tap",
9666
- style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9667
- children: [
9668
- /* @__PURE__ */ jsxRuntime.jsx(
9669
- "img",
9670
- {
9671
- src: shotUrl,
9672
- alt: "capture",
9673
- className: "qa-max-h-32 qa-rounded-md"
9674
- }
9675
- ),
9702
+ children: [
9703
+ capturing ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9704
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9705
+ t("capturing")
9706
+ ] }) : shotUrl ? /* @__PURE__ */ jsxRuntime.jsxs(
9707
+ "button",
9708
+ {
9709
+ type: "button",
9710
+ onClick: () => setDrawing(true),
9711
+ title: t("draw_label"),
9712
+ "aria-label": t("draw_label"),
9713
+ className: "qa-relative qa-group qa-tap",
9714
+ style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9715
+ children: [
9716
+ /* @__PURE__ */ jsxRuntime.jsx(
9717
+ "img",
9718
+ {
9719
+ src: shotUrl,
9720
+ alt: "capture",
9721
+ className: "qa-max-h-32 qa-rounded-md"
9722
+ }
9723
+ ),
9724
+ /* @__PURE__ */ jsxRuntime.jsxs(
9725
+ "span",
9726
+ {
9727
+ className: "qa-absolute qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-px-1.5 qa-py-0.5 qa-text-10",
9728
+ style: {
9729
+ bottom: 4,
9730
+ insetInlineEnd: 4,
9731
+ background: "var(--qa-surface-1)",
9732
+ color: "var(--qa-ink-hi)",
9733
+ border: "1px solid var(--qa-border-subtle)"
9734
+ },
9735
+ children: [
9736
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Pencil", size: 10 }),
9737
+ t("draw_label")
9738
+ ]
9739
+ }
9740
+ )
9741
+ ]
9742
+ }
9743
+ ) : captureError ? (
9744
+ // The render broke rather than being skipped — offer a retry
9745
+ // against the same selection instead of a dead-end message.
9746
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9747
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9676
9748
  /* @__PURE__ */ jsxRuntime.jsxs(
9677
- "span",
9749
+ "button",
9678
9750
  {
9679
- className: "qa-absolute qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-px-1.5 qa-py-0.5 qa-text-10",
9680
- style: {
9681
- bottom: 4,
9682
- insetInlineEnd: 4,
9683
- background: "var(--qa-surface-1)",
9684
- color: "var(--qa-ink-hi)",
9685
- border: "1px solid var(--qa-border-subtle)"
9686
- },
9751
+ type: "button",
9752
+ onClick: () => selection && void runCapture(selection.rect),
9753
+ 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-mid qa-focus-ring",
9754
+ style: { background: "transparent", cursor: "pointer" },
9687
9755
  children: [
9688
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Pencil", size: 10 }),
9689
- t("draw_label")
9756
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "RotateCcw", size: 13 }),
9757
+ t("retry")
9690
9758
  ]
9691
9759
  }
9692
9760
  )
9693
- ]
9694
- }
9695
- ) : captureError ? (
9696
- // The render broke rather than being skipped — offer a retry
9697
- // against the same selection instead of a dead-end message.
9698
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9699
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9761
+ ] })
9762
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") }),
9763
+ shotUrl && shotEngine === "dom" && (exactShots.status !== "live" && exactShots.supported ? /* @__PURE__ */ jsxRuntime.jsxs(
9764
+ "span",
9765
+ {
9766
+ "data-qa-redraw-offer": "true",
9767
+ className: "qa-mt-1.5 qa-flex qa-items-center qa-justify-center qa-flex-wrap qa-gap-1.5 qa-text-10 qa-text-mid",
9768
+ children: [
9769
+ t("redraw_notice"),
9770
+ /* @__PURE__ */ jsxRuntime.jsxs(
9771
+ "button",
9772
+ {
9773
+ type: "button",
9774
+ onClick: () => void (async () => {
9775
+ const granted = await enableExactShots();
9776
+ if (granted && selection) void runCapture(selection.rect);
9777
+ })(),
9778
+ title: t("exact_hint"),
9779
+ className: "qa-tap qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-subtle qa-px-1.5 qa-py-0.5 qa-text-10 qa-text-hi qa-focus-ring",
9780
+ style: { background: "transparent", cursor: "pointer" },
9781
+ children: [
9782
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 10 }),
9783
+ t("redraw_action")
9784
+ ]
9785
+ }
9786
+ )
9787
+ ]
9788
+ }
9789
+ ) : !exactShots.supported ? (
9790
+ // Safari and Firefox have no way to photograph the tab, so
9791
+ // there is nothing to offer — but saying NOTHING was the real
9792
+ // failure. A tester watching an SVG chart come back as bare
9793
+ // outlines concludes the tool is broken and files it as a bug,
9794
+ // over and over. Name the limit and name the way out.
9700
9795
  /* @__PURE__ */ jsxRuntime.jsxs(
9701
- "button",
9796
+ "span",
9702
9797
  {
9703
- type: "button",
9704
- onClick: () => selection && void runCapture(selection.rect),
9705
- 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-mid qa-focus-ring",
9706
- style: { background: "transparent", cursor: "pointer" },
9798
+ "data-qa-redraw-note": "true",
9799
+ className: "qa-mt-1.5 qa-block qa-text-center qa-text-10 qa-text-mid",
9707
9800
  children: [
9708
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "RotateCcw", size: 13 }),
9709
- t("retry")
9801
+ t("redraw_notice"),
9802
+ " ",
9803
+ t("redraw_no_engine")
9710
9804
  ]
9711
9805
  }
9712
9806
  )
9713
- ] })
9714
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") })
9807
+ ) : null)
9808
+ ]
9715
9809
  }
9716
9810
  ),
9717
9811
  /* @__PURE__ */ jsxRuntime.jsx(LocationReveal, { target: selection }),
@@ -10005,5 +10099,5 @@ function Qapture({ config }) {
10005
10099
  exports.Qapture = Qapture;
10006
10100
  exports.deleteQaDatabase = deleteQaDatabase;
10007
10101
  exports.initQaStudio = initQaStudio;
10008
- //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10009
- //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10102
+ //# sourceMappingURL=chunk-7WHQBMVP.cjs.map
10103
+ //# sourceMappingURL=chunk-7WHQBMVP.cjs.map
@@ -1775,6 +1775,9 @@ var STR = {
1775
1775
  expected_label: "Expected",
1776
1776
  exact_label: "Pixel-exact shots",
1777
1777
  exact_hint: "Screenshots become a real photo of the tab instead of a redraw. Asks once to share this tab; nothing leaves your device.",
1778
+ redraw_notice: "This is a redraw of the page, not a real photo.",
1779
+ redraw_action: "Use real screenshots",
1780
+ redraw_no_engine: "This browser cannot photograph a tab, so charts and canvases may come out wrong. Open the app in Chrome, Edge or Brave for true screenshots.",
1778
1781
  exact_on: "Pixel-exact screenshots on",
1779
1782
  exact_off: "Back to redrawn screenshots",
1780
1783
  exact_declined: "Staying on redrawn screenshots",
@@ -1969,6 +1972,9 @@ var STR = {
1969
1972
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1970
1973
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1971
1974
  exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0644\u0644\u062A\u0628\u0648\u064A\u0628 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645\u0647. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1975
+ redraw_notice: "\u0647\u0630\u0647 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645 \u0644\u0644\u0635\u0641\u062D\u0629\u060C \u0648\u0644\u064A\u0633\u062A \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629.",
1976
+ redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1977
+ redraw_no_engine: "\u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u062A\u0628\u0648\u064A\u0628\u060C \u0644\u0630\u0627 \u0642\u062F \u062A\u0638\u0647\u0631 \u0627\u0644\u0645\u062E\u0637\u0637\u0627\u062A \u0648\u0627\u0644\u0644\u0648\u062D\u0627\u062A \u0628\u0634\u0643\u0644 \u062E\u0627\u0637\u0626. \u0627\u0641\u062A\u062D \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0641\u064A Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
1972
1978
  exact_on: "\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u0637\u0627\u0628\u0642\u0629",
1973
1979
  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",
1974
1980
  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",
@@ -2531,7 +2537,7 @@ var MAX_SHOT_EDGE = 1800;
2531
2537
  var WEBP_QUALITY = 0.92;
2532
2538
  var STUCK_ATTR = "data-qa-stuck";
2533
2539
  var MAX_CAPTURE_EDGE = 4e3;
2534
- var MAX_RENDER_VIEWPORTS = 4;
2540
+ var MAX_RENDER_DEVICE_PIXELS = 16e6;
2535
2541
  function withTimeout(promise, ms) {
2536
2542
  let timer;
2537
2543
  return Promise.race([
@@ -2703,6 +2709,39 @@ function viewportSize() {
2703
2709
  vh: document.documentElement.clientHeight || window.innerHeight
2704
2710
  };
2705
2711
  }
2712
+ function clipToPaintedArea(el, rect) {
2713
+ let left = rect.left;
2714
+ let top = rect.top;
2715
+ let right = rect.left + rect.width;
2716
+ let bottom = rect.top + rect.height;
2717
+ let node = el.parentElement;
2718
+ while (node) {
2719
+ let style;
2720
+ try {
2721
+ style = getComputedStyle(node);
2722
+ } catch {
2723
+ break;
2724
+ }
2725
+ const clipsX = style.overflowX !== "visible";
2726
+ const clipsY = style.overflowY !== "visible";
2727
+ if (clipsX || clipsY) {
2728
+ const b = node.getBoundingClientRect();
2729
+ const padLeft = b.left + node.clientLeft;
2730
+ const padTop = b.top + node.clientTop;
2731
+ if (clipsX) {
2732
+ left = Math.max(left, padLeft);
2733
+ right = Math.min(right, padLeft + node.clientWidth);
2734
+ }
2735
+ if (clipsY) {
2736
+ top = Math.max(top, padTop);
2737
+ bottom = Math.min(bottom, padTop + node.clientHeight);
2738
+ }
2739
+ }
2740
+ node = node.parentElement;
2741
+ }
2742
+ if (right - left < 2 || bottom - top < 2) return rect;
2743
+ return { left, top, width: right - left, height: bottom - top };
2744
+ }
2706
2745
  function intersectViewport(rect) {
2707
2746
  const { vw, vh } = viewportSize();
2708
2747
  const left = Math.max(0, rect.left);
@@ -2739,9 +2778,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2739
2778
  const { vw, vh } = viewportSize();
2740
2779
  const plan = planRender(rect, sx, sy, vw, vh);
2741
2780
  let scale = Math.min(window.devicePixelRatio || 1, 2);
2742
- const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2743
2781
  const area = plan.render.width * plan.render.height;
2744
- if (area > budget) scale *= Math.sqrt(budget / area);
2782
+ if (area * scale * scale > MAX_RENDER_DEVICE_PIXELS) {
2783
+ scale = Math.sqrt(MAX_RENDER_DEVICE_PIXELS / area);
2784
+ }
2745
2785
  const marked = markStuckElements();
2746
2786
  try {
2747
2787
  const full = await withTimeout(
@@ -8873,6 +8913,7 @@ function CaptureMode() {
8873
8913
  const [candidate, setCandidate] = useState(null);
8874
8914
  const [regionMode, setRegionMode] = useState(false);
8875
8915
  const [shot, setShot] = useState(null);
8916
+ const [shotEngine, setShotEngine] = useState(null);
8876
8917
  const [shotUrl, setShotUrl] = useState(null);
8877
8918
  const [capturing, setCapturing] = useState(false);
8878
8919
  const [captureError, setCaptureError] = useState(false);
@@ -8915,6 +8956,7 @@ function CaptureMode() {
8915
8956
  return;
8916
8957
  }
8917
8958
  setCaptureError(outcome.status === "failed");
8959
+ setShotEngine(outcome.status === "ok" ? outcome.engine : null);
8918
8960
  setShot(blob);
8919
8961
  setShotUrl((old) => {
8920
8962
  if (old) URL.revokeObjectURL(old);
@@ -8979,7 +9021,9 @@ function CaptureMode() {
8979
9021
  }
8980
9022
  const r = el.getBoundingClientRect();
8981
9023
  setHover({
8982
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9024
+ // Highlight only what will actually be captured, so the outline is a
9025
+ // promise the screenshot can keep.
9026
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
8983
9027
  selector: getStableSelector(el)
8984
9028
  });
8985
9029
  }
@@ -9026,7 +9070,10 @@ function CaptureMode() {
9026
9070
  const r = el.getBoundingClientRect();
9027
9071
  const sel = {
9028
9072
  kind: "element",
9029
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9073
+ // What the browser PAINTS of this element, not its full bounding box —
9074
+ // a row inside a scrolled table is only ever drawn where the table shows
9075
+ // it. See clipToPaintedArea().
9076
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
9030
9077
  selector: getStableSelector(el),
9031
9078
  text: (el.innerText ?? el.textContent ?? "").trim().slice(0, 120),
9032
9079
  tagName: el.tagName.toLowerCase()
@@ -9637,7 +9684,7 @@ function CaptureMode() {
9637
9684
  )
9638
9685
  ] }),
9639
9686
  /* @__PURE__ */ jsxs("div", { className: "qa-space-y-2 qa-p-3", children: [
9640
- /* @__PURE__ */ jsx(
9687
+ /* @__PURE__ */ jsxs(
9641
9688
  "div",
9642
9689
  {
9643
9690
  className: "qa-flex qa-min-h-16 qa-items-center qa-justify-center qa-rounded-lg qa-border",
@@ -9645,66 +9692,113 @@ function CaptureMode() {
9645
9692
  borderColor: "var(--qa-border-subtle)",
9646
9693
  background: "var(--qa-surface-0)"
9647
9694
  },
9648
- children: capturing ? /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9649
- /* @__PURE__ */ jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9650
- t("capturing")
9651
- ] }) : shotUrl ? /* @__PURE__ */ jsxs(
9652
- "button",
9653
- {
9654
- type: "button",
9655
- onClick: () => setDrawing(true),
9656
- title: t("draw_label"),
9657
- "aria-label": t("draw_label"),
9658
- className: "qa-relative qa-group qa-tap",
9659
- style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9660
- children: [
9661
- /* @__PURE__ */ jsx(
9662
- "img",
9663
- {
9664
- src: shotUrl,
9665
- alt: "capture",
9666
- className: "qa-max-h-32 qa-rounded-md"
9667
- }
9668
- ),
9695
+ children: [
9696
+ capturing ? /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9697
+ /* @__PURE__ */ jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9698
+ t("capturing")
9699
+ ] }) : shotUrl ? /* @__PURE__ */ jsxs(
9700
+ "button",
9701
+ {
9702
+ type: "button",
9703
+ onClick: () => setDrawing(true),
9704
+ title: t("draw_label"),
9705
+ "aria-label": t("draw_label"),
9706
+ className: "qa-relative qa-group qa-tap",
9707
+ style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9708
+ children: [
9709
+ /* @__PURE__ */ jsx(
9710
+ "img",
9711
+ {
9712
+ src: shotUrl,
9713
+ alt: "capture",
9714
+ className: "qa-max-h-32 qa-rounded-md"
9715
+ }
9716
+ ),
9717
+ /* @__PURE__ */ jsxs(
9718
+ "span",
9719
+ {
9720
+ className: "qa-absolute qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-px-1.5 qa-py-0.5 qa-text-10",
9721
+ style: {
9722
+ bottom: 4,
9723
+ insetInlineEnd: 4,
9724
+ background: "var(--qa-surface-1)",
9725
+ color: "var(--qa-ink-hi)",
9726
+ border: "1px solid var(--qa-border-subtle)"
9727
+ },
9728
+ children: [
9729
+ /* @__PURE__ */ jsx(Icon, { name: "Pencil", size: 10 }),
9730
+ t("draw_label")
9731
+ ]
9732
+ }
9733
+ )
9734
+ ]
9735
+ }
9736
+ ) : captureError ? (
9737
+ // The render broke rather than being skipped — offer a retry
9738
+ // against the same selection instead of a dead-end message.
9739
+ /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9740
+ /* @__PURE__ */ jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9669
9741
  /* @__PURE__ */ jsxs(
9670
- "span",
9742
+ "button",
9671
9743
  {
9672
- className: "qa-absolute qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-px-1.5 qa-py-0.5 qa-text-10",
9673
- style: {
9674
- bottom: 4,
9675
- insetInlineEnd: 4,
9676
- background: "var(--qa-surface-1)",
9677
- color: "var(--qa-ink-hi)",
9678
- border: "1px solid var(--qa-border-subtle)"
9679
- },
9744
+ type: "button",
9745
+ onClick: () => selection && void runCapture(selection.rect),
9746
+ 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-mid qa-focus-ring",
9747
+ style: { background: "transparent", cursor: "pointer" },
9680
9748
  children: [
9681
- /* @__PURE__ */ jsx(Icon, { name: "Pencil", size: 10 }),
9682
- t("draw_label")
9749
+ /* @__PURE__ */ jsx(Icon, { name: "RotateCcw", size: 13 }),
9750
+ t("retry")
9683
9751
  ]
9684
9752
  }
9685
9753
  )
9686
- ]
9687
- }
9688
- ) : captureError ? (
9689
- // The render broke rather than being skipped — offer a retry
9690
- // against the same selection instead of a dead-end message.
9691
- /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9692
- /* @__PURE__ */ jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9754
+ ] })
9755
+ ) : /* @__PURE__ */ jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") }),
9756
+ shotUrl && shotEngine === "dom" && (exactShots.status !== "live" && exactShots.supported ? /* @__PURE__ */ jsxs(
9757
+ "span",
9758
+ {
9759
+ "data-qa-redraw-offer": "true",
9760
+ className: "qa-mt-1.5 qa-flex qa-items-center qa-justify-center qa-flex-wrap qa-gap-1.5 qa-text-10 qa-text-mid",
9761
+ children: [
9762
+ t("redraw_notice"),
9763
+ /* @__PURE__ */ jsxs(
9764
+ "button",
9765
+ {
9766
+ type: "button",
9767
+ onClick: () => void (async () => {
9768
+ const granted = await enableExactShots();
9769
+ if (granted && selection) void runCapture(selection.rect);
9770
+ })(),
9771
+ title: t("exact_hint"),
9772
+ className: "qa-tap qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-subtle qa-px-1.5 qa-py-0.5 qa-text-10 qa-text-hi qa-focus-ring",
9773
+ style: { background: "transparent", cursor: "pointer" },
9774
+ children: [
9775
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 10 }),
9776
+ t("redraw_action")
9777
+ ]
9778
+ }
9779
+ )
9780
+ ]
9781
+ }
9782
+ ) : !exactShots.supported ? (
9783
+ // Safari and Firefox have no way to photograph the tab, so
9784
+ // there is nothing to offer — but saying NOTHING was the real
9785
+ // failure. A tester watching an SVG chart come back as bare
9786
+ // outlines concludes the tool is broken and files it as a bug,
9787
+ // over and over. Name the limit and name the way out.
9693
9788
  /* @__PURE__ */ jsxs(
9694
- "button",
9789
+ "span",
9695
9790
  {
9696
- type: "button",
9697
- onClick: () => selection && void runCapture(selection.rect),
9698
- 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-mid qa-focus-ring",
9699
- style: { background: "transparent", cursor: "pointer" },
9791
+ "data-qa-redraw-note": "true",
9792
+ className: "qa-mt-1.5 qa-block qa-text-center qa-text-10 qa-text-mid",
9700
9793
  children: [
9701
- /* @__PURE__ */ jsx(Icon, { name: "RotateCcw", size: 13 }),
9702
- t("retry")
9794
+ t("redraw_notice"),
9795
+ " ",
9796
+ t("redraw_no_engine")
9703
9797
  ]
9704
9798
  }
9705
9799
  )
9706
- ] })
9707
- ) : /* @__PURE__ */ jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") })
9800
+ ) : null)
9801
+ ]
9708
9802
  }
9709
9803
  ),
9710
9804
  /* @__PURE__ */ jsx(LocationReveal, { target: selection }),
@@ -9996,5 +10090,5 @@ function Qapture({ config }) {
9996
10090
  }
9997
10091
 
9998
10092
  export { Qapture, deleteQaDatabase, initQaStudio };
9999
- //# sourceMappingURL=chunk-NVFFN7SC.js.map
10000
- //# sourceMappingURL=chunk-NVFFN7SC.js.map
10093
+ //# sourceMappingURL=chunk-ZAWR5VJR.js.map
10094
+ //# sourceMappingURL=chunk-ZAWR5VJR.js.map
package/dist/index.cjs CHANGED
@@ -1,24 +1,24 @@
1
1
  'use strict';
2
2
 
3
- var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.cjs');
3
+ var chunk7WHQBMVP_cjs = require('./chunk-7WHQBMVP.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "QaStudio", {
8
8
  enumerable: true,
9
- get: function () { return chunkCIFJMD2B_cjs.Qapture; }
9
+ get: function () { return chunk7WHQBMVP_cjs.Qapture; }
10
10
  });
11
11
  Object.defineProperty(exports, "Qapture", {
12
12
  enumerable: true,
13
- get: function () { return chunkCIFJMD2B_cjs.Qapture; }
13
+ get: function () { return chunk7WHQBMVP_cjs.Qapture; }
14
14
  });
15
15
  Object.defineProperty(exports, "deleteQaDatabase", {
16
16
  enumerable: true,
17
- get: function () { return chunkCIFJMD2B_cjs.deleteQaDatabase; }
17
+ get: function () { return chunk7WHQBMVP_cjs.deleteQaDatabase; }
18
18
  });
19
19
  Object.defineProperty(exports, "initQaStudio", {
20
20
  enumerable: true,
21
- get: function () { return chunkCIFJMD2B_cjs.initQaStudio; }
21
+ get: function () { return chunk7WHQBMVP_cjs.initQaStudio; }
22
22
  });
23
23
  //# sourceMappingURL=index.cjs.map
24
24
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-NVFFN7SC.js';
1
+ export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-ZAWR5VJR.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/next.cjs CHANGED
@@ -1,21 +1,21 @@
1
1
  "use client";
2
2
  'use strict';
3
3
 
4
- var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.cjs');
4
+ var chunk7WHQBMVP_cjs = require('./chunk-7WHQBMVP.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "QaStudio", {
9
9
  enumerable: true,
10
- get: function () { return chunkCIFJMD2B_cjs.Qapture; }
10
+ get: function () { return chunk7WHQBMVP_cjs.Qapture; }
11
11
  });
12
12
  Object.defineProperty(exports, "Qapture", {
13
13
  enumerable: true,
14
- get: function () { return chunkCIFJMD2B_cjs.Qapture; }
14
+ get: function () { return chunk7WHQBMVP_cjs.Qapture; }
15
15
  });
16
16
  Object.defineProperty(exports, "initQaStudio", {
17
17
  enumerable: true,
18
- get: function () { return chunkCIFJMD2B_cjs.initQaStudio; }
18
+ get: function () { return chunk7WHQBMVP_cjs.initQaStudio; }
19
19
  });
20
20
  //# sourceMappingURL=next.cjs.map
21
21
  //# sourceMappingURL=next.cjs.map
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-NVFFN7SC.js';
2
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-ZAWR5VJR.js';
3
3
  //# sourceMappingURL=next.js.map
4
4
  //# sourceMappingURL=next.js.map
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.cjs');
3
+ var chunk7WHQBMVP_cjs = require('./chunk-7WHQBMVP.cjs');
4
4
 
5
5
  // src/standalone.ts
6
6
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
@@ -38,7 +38,7 @@ if (typeof window !== "undefined" && typeof customElements !== "undefined" && !c
38
38
 
39
39
  Object.defineProperty(exports, "initQaStudio", {
40
40
  enumerable: true,
41
- get: function () { return chunkCIFJMD2B_cjs.initQaStudio; }
41
+ get: function () { return chunk7WHQBMVP_cjs.initQaStudio; }
42
42
  });
43
43
  //# sourceMappingURL=standalone.cjs.map
44
44
  //# sourceMappingURL=standalone.cjs.map
@@ -1,4 +1,4 @@
1
- export { initQaStudio } from './chunk-NVFFN7SC.js';
1
+ export { initQaStudio } from './chunk-ZAWR5VJR.js';
2
2
 
3
3
  // src/standalone.ts
4
4
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qapture2",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Drop-in, AI-aware in-browser QA capture widget. A tester annotates the live app (element/region + auto-screenshot + note), tracks a graded testing journey, and exports a ZIP your own coding agent reads. Ships zero AI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,6 +90,7 @@
90
90
  "browser-test": "node scripts/browser-test.mjs",
91
91
  "capture-accuracy-test": "node scripts/capture-accuracy-test.mjs",
92
92
  "element-capture-test": "node scripts/element-capture-test.mjs",
93
+ "svg-vars-test": "node scripts/svg-vars-test.mjs",
93
94
  "loop-features-test": "node scripts/loop-features-test.mjs",
94
95
  "walk-test": "node scripts/walk-test.mjs",
95
96
  "modern-css-test": "node scripts/modern-css-test.mjs",