qapture2 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -326,10 +326,34 @@ 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. Chromium desktop only — on Safari and Firefox the
355
+ redraw is the only engine there is, so for screenshot-critical QA, test in
356
+ Chrome.
333
357
 
334
358
  ### `exact` — opt-in, pixel-for-pixel
335
359
 
@@ -1775,6 +1775,8 @@ 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",
1778
1780
  exact_on: "Pixel-exact screenshots on",
1779
1781
  exact_off: "Back to redrawn screenshots",
1780
1782
  exact_declined: "Staying on redrawn screenshots",
@@ -1969,6 +1971,8 @@ var STR = {
1969
1971
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1970
1972
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1971
1973
  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.",
1974
+ 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.",
1975
+ redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1972
1976
  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
1977
  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
1978
  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 +2535,7 @@ var MAX_SHOT_EDGE = 1800;
2531
2535
  var WEBP_QUALITY = 0.92;
2532
2536
  var STUCK_ATTR = "data-qa-stuck";
2533
2537
  var MAX_CAPTURE_EDGE = 4e3;
2534
- var MAX_RENDER_VIEWPORTS = 4;
2538
+ var MAX_RENDER_DEVICE_PIXELS = 16e6;
2535
2539
  function withTimeout(promise, ms) {
2536
2540
  let timer;
2537
2541
  return Promise.race([
@@ -2703,6 +2707,39 @@ function viewportSize() {
2703
2707
  vh: document.documentElement.clientHeight || window.innerHeight
2704
2708
  };
2705
2709
  }
2710
+ function clipToPaintedArea(el, rect) {
2711
+ let left = rect.left;
2712
+ let top = rect.top;
2713
+ let right = rect.left + rect.width;
2714
+ let bottom = rect.top + rect.height;
2715
+ let node = el.parentElement;
2716
+ while (node) {
2717
+ let style;
2718
+ try {
2719
+ style = getComputedStyle(node);
2720
+ } catch {
2721
+ break;
2722
+ }
2723
+ const clipsX = style.overflowX !== "visible";
2724
+ const clipsY = style.overflowY !== "visible";
2725
+ if (clipsX || clipsY) {
2726
+ const b = node.getBoundingClientRect();
2727
+ const padLeft = b.left + node.clientLeft;
2728
+ const padTop = b.top + node.clientTop;
2729
+ if (clipsX) {
2730
+ left = Math.max(left, padLeft);
2731
+ right = Math.min(right, padLeft + node.clientWidth);
2732
+ }
2733
+ if (clipsY) {
2734
+ top = Math.max(top, padTop);
2735
+ bottom = Math.min(bottom, padTop + node.clientHeight);
2736
+ }
2737
+ }
2738
+ node = node.parentElement;
2739
+ }
2740
+ if (right - left < 2 || bottom - top < 2) return rect;
2741
+ return { left, top, width: right - left, height: bottom - top };
2742
+ }
2706
2743
  function intersectViewport(rect) {
2707
2744
  const { vw, vh } = viewportSize();
2708
2745
  const left = Math.max(0, rect.left);
@@ -2739,9 +2776,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2739
2776
  const { vw, vh } = viewportSize();
2740
2777
  const plan = planRender(rect, sx, sy, vw, vh);
2741
2778
  let scale = Math.min(window.devicePixelRatio || 1, 2);
2742
- const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2743
2779
  const area = plan.render.width * plan.render.height;
2744
- if (area > budget) scale *= Math.sqrt(budget / area);
2780
+ if (area * scale * scale > MAX_RENDER_DEVICE_PIXELS) {
2781
+ scale = Math.sqrt(MAX_RENDER_DEVICE_PIXELS / area);
2782
+ }
2745
2783
  const marked = markStuckElements();
2746
2784
  try {
2747
2785
  const full = await withTimeout(
@@ -8873,6 +8911,7 @@ function CaptureMode() {
8873
8911
  const [candidate, setCandidate] = useState(null);
8874
8912
  const [regionMode, setRegionMode] = useState(false);
8875
8913
  const [shot, setShot] = useState(null);
8914
+ const [shotEngine, setShotEngine] = useState(null);
8876
8915
  const [shotUrl, setShotUrl] = useState(null);
8877
8916
  const [capturing, setCapturing] = useState(false);
8878
8917
  const [captureError, setCaptureError] = useState(false);
@@ -8915,6 +8954,7 @@ function CaptureMode() {
8915
8954
  return;
8916
8955
  }
8917
8956
  setCaptureError(outcome.status === "failed");
8957
+ setShotEngine(outcome.status === "ok" ? outcome.engine : null);
8918
8958
  setShot(blob);
8919
8959
  setShotUrl((old) => {
8920
8960
  if (old) URL.revokeObjectURL(old);
@@ -8979,7 +9019,9 @@ function CaptureMode() {
8979
9019
  }
8980
9020
  const r = el.getBoundingClientRect();
8981
9021
  setHover({
8982
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9022
+ // Highlight only what will actually be captured, so the outline is a
9023
+ // promise the screenshot can keep.
9024
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
8983
9025
  selector: getStableSelector(el)
8984
9026
  });
8985
9027
  }
@@ -9026,7 +9068,10 @@ function CaptureMode() {
9026
9068
  const r = el.getBoundingClientRect();
9027
9069
  const sel = {
9028
9070
  kind: "element",
9029
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9071
+ // What the browser PAINTS of this element, not its full bounding box —
9072
+ // a row inside a scrolled table is only ever drawn where the table shows
9073
+ // it. See clipToPaintedArea().
9074
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
9030
9075
  selector: getStableSelector(el),
9031
9076
  text: (el.innerText ?? el.textContent ?? "").trim().slice(0, 120),
9032
9077
  tagName: el.tagName.toLowerCase()
@@ -9637,7 +9682,7 @@ function CaptureMode() {
9637
9682
  )
9638
9683
  ] }),
9639
9684
  /* @__PURE__ */ jsxs("div", { className: "qa-space-y-2 qa-p-3", children: [
9640
- /* @__PURE__ */ jsx(
9685
+ /* @__PURE__ */ jsxs(
9641
9686
  "div",
9642
9687
  {
9643
9688
  className: "qa-flex qa-min-h-16 qa-items-center qa-justify-center qa-rounded-lg qa-border",
@@ -9645,66 +9690,95 @@ function CaptureMode() {
9645
9690
  borderColor: "var(--qa-border-subtle)",
9646
9691
  background: "var(--qa-surface-0)"
9647
9692
  },
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
- ),
9693
+ children: [
9694
+ capturing ? /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9695
+ /* @__PURE__ */ jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9696
+ t("capturing")
9697
+ ] }) : shotUrl ? /* @__PURE__ */ jsxs(
9698
+ "button",
9699
+ {
9700
+ type: "button",
9701
+ onClick: () => setDrawing(true),
9702
+ title: t("draw_label"),
9703
+ "aria-label": t("draw_label"),
9704
+ className: "qa-relative qa-group qa-tap",
9705
+ style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9706
+ children: [
9707
+ /* @__PURE__ */ jsx(
9708
+ "img",
9709
+ {
9710
+ src: shotUrl,
9711
+ alt: "capture",
9712
+ className: "qa-max-h-32 qa-rounded-md"
9713
+ }
9714
+ ),
9715
+ /* @__PURE__ */ jsxs(
9716
+ "span",
9717
+ {
9718
+ 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",
9719
+ style: {
9720
+ bottom: 4,
9721
+ insetInlineEnd: 4,
9722
+ background: "var(--qa-surface-1)",
9723
+ color: "var(--qa-ink-hi)",
9724
+ border: "1px solid var(--qa-border-subtle)"
9725
+ },
9726
+ children: [
9727
+ /* @__PURE__ */ jsx(Icon, { name: "Pencil", size: 10 }),
9728
+ t("draw_label")
9729
+ ]
9730
+ }
9731
+ )
9732
+ ]
9733
+ }
9734
+ ) : captureError ? (
9735
+ // The render broke rather than being skipped — offer a retry
9736
+ // against the same selection instead of a dead-end message.
9737
+ /* @__PURE__ */ jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9738
+ /* @__PURE__ */ jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9669
9739
  /* @__PURE__ */ jsxs(
9670
- "span",
9740
+ "button",
9671
9741
  {
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
- },
9742
+ type: "button",
9743
+ onClick: () => selection && void runCapture(selection.rect),
9744
+ 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",
9745
+ style: { background: "transparent", cursor: "pointer" },
9680
9746
  children: [
9681
- /* @__PURE__ */ jsx(Icon, { name: "Pencil", size: 10 }),
9682
- t("draw_label")
9747
+ /* @__PURE__ */ jsx(Icon, { name: "RotateCcw", size: 13 }),
9748
+ t("retry")
9683
9749
  ]
9684
9750
  }
9685
9751
  )
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") }),
9693
- /* @__PURE__ */ jsxs(
9694
- "button",
9695
- {
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" },
9700
- children: [
9701
- /* @__PURE__ */ jsx(Icon, { name: "RotateCcw", size: 13 }),
9702
- t("retry")
9703
- ]
9704
- }
9705
- )
9706
- ] })
9707
- ) : /* @__PURE__ */ jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") })
9752
+ ] })
9753
+ ) : /* @__PURE__ */ jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") }),
9754
+ shotUrl && shotEngine === "dom" && exactShots.supported && exactShots.status !== "live" && /* @__PURE__ */ jsxs(
9755
+ "span",
9756
+ {
9757
+ "data-qa-redraw-offer": "true",
9758
+ 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",
9759
+ children: [
9760
+ t("redraw_notice"),
9761
+ /* @__PURE__ */ jsxs(
9762
+ "button",
9763
+ {
9764
+ type: "button",
9765
+ onClick: () => void (async () => {
9766
+ const granted = await enableExactShots();
9767
+ if (granted && selection) void runCapture(selection.rect);
9768
+ })(),
9769
+ title: t("exact_hint"),
9770
+ 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",
9771
+ style: { background: "transparent", cursor: "pointer" },
9772
+ children: [
9773
+ /* @__PURE__ */ jsx(Icon, { name: "Camera", size: 10 }),
9774
+ t("redraw_action")
9775
+ ]
9776
+ }
9777
+ )
9778
+ ]
9779
+ }
9780
+ )
9781
+ ]
9708
9782
  }
9709
9783
  ),
9710
9784
  /* @__PURE__ */ jsx(LocationReveal, { target: selection }),
@@ -9996,5 +10070,5 @@ function Qapture({ config }) {
9996
10070
  }
9997
10071
 
9998
10072
  export { Qapture, deleteQaDatabase, initQaStudio };
9999
- //# sourceMappingURL=chunk-NVFFN7SC.js.map
10000
- //# sourceMappingURL=chunk-NVFFN7SC.js.map
10073
+ //# sourceMappingURL=chunk-BM3ESR2O.js.map
10074
+ //# sourceMappingURL=chunk-BM3ESR2O.js.map
@@ -1782,6 +1782,8 @@ 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",
1785
1787
  exact_on: "Pixel-exact screenshots on",
1786
1788
  exact_off: "Back to redrawn screenshots",
1787
1789
  exact_declined: "Staying on redrawn screenshots",
@@ -1976,6 +1978,8 @@ var STR = {
1976
1978
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1977
1979
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1978
1980
  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.",
1981
+ 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.",
1982
+ redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1979
1983
  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
1984
  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
1985
  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 +2542,7 @@ var MAX_SHOT_EDGE = 1800;
2538
2542
  var WEBP_QUALITY = 0.92;
2539
2543
  var STUCK_ATTR = "data-qa-stuck";
2540
2544
  var MAX_CAPTURE_EDGE = 4e3;
2541
- var MAX_RENDER_VIEWPORTS = 4;
2545
+ var MAX_RENDER_DEVICE_PIXELS = 16e6;
2542
2546
  function withTimeout(promise, ms) {
2543
2547
  let timer;
2544
2548
  return Promise.race([
@@ -2710,6 +2714,39 @@ function viewportSize() {
2710
2714
  vh: document.documentElement.clientHeight || window.innerHeight
2711
2715
  };
2712
2716
  }
2717
+ function clipToPaintedArea(el, rect) {
2718
+ let left = rect.left;
2719
+ let top = rect.top;
2720
+ let right = rect.left + rect.width;
2721
+ let bottom = rect.top + rect.height;
2722
+ let node = el.parentElement;
2723
+ while (node) {
2724
+ let style;
2725
+ try {
2726
+ style = getComputedStyle(node);
2727
+ } catch {
2728
+ break;
2729
+ }
2730
+ const clipsX = style.overflowX !== "visible";
2731
+ const clipsY = style.overflowY !== "visible";
2732
+ if (clipsX || clipsY) {
2733
+ const b = node.getBoundingClientRect();
2734
+ const padLeft = b.left + node.clientLeft;
2735
+ const padTop = b.top + node.clientTop;
2736
+ if (clipsX) {
2737
+ left = Math.max(left, padLeft);
2738
+ right = Math.min(right, padLeft + node.clientWidth);
2739
+ }
2740
+ if (clipsY) {
2741
+ top = Math.max(top, padTop);
2742
+ bottom = Math.min(bottom, padTop + node.clientHeight);
2743
+ }
2744
+ }
2745
+ node = node.parentElement;
2746
+ }
2747
+ if (right - left < 2 || bottom - top < 2) return rect;
2748
+ return { left, top, width: right - left, height: bottom - top };
2749
+ }
2713
2750
  function intersectViewport(rect) {
2714
2751
  const { vw, vh } = viewportSize();
2715
2752
  const left = Math.max(0, rect.left);
@@ -2746,9 +2783,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2746
2783
  const { vw, vh } = viewportSize();
2747
2784
  const plan = planRender(rect, sx, sy, vw, vh);
2748
2785
  let scale = Math.min(window.devicePixelRatio || 1, 2);
2749
- const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2750
2786
  const area = plan.render.width * plan.render.height;
2751
- if (area > budget) scale *= Math.sqrt(budget / area);
2787
+ if (area * scale * scale > MAX_RENDER_DEVICE_PIXELS) {
2788
+ scale = Math.sqrt(MAX_RENDER_DEVICE_PIXELS / area);
2789
+ }
2752
2790
  const marked = markStuckElements();
2753
2791
  try {
2754
2792
  const full = await withTimeout(
@@ -8880,6 +8918,7 @@ function CaptureMode() {
8880
8918
  const [candidate, setCandidate] = React.useState(null);
8881
8919
  const [regionMode, setRegionMode] = React.useState(false);
8882
8920
  const [shot, setShot] = React.useState(null);
8921
+ const [shotEngine, setShotEngine] = React.useState(null);
8883
8922
  const [shotUrl, setShotUrl] = React.useState(null);
8884
8923
  const [capturing, setCapturing] = React.useState(false);
8885
8924
  const [captureError, setCaptureError] = React.useState(false);
@@ -8922,6 +8961,7 @@ function CaptureMode() {
8922
8961
  return;
8923
8962
  }
8924
8963
  setCaptureError(outcome.status === "failed");
8964
+ setShotEngine(outcome.status === "ok" ? outcome.engine : null);
8925
8965
  setShot(blob);
8926
8966
  setShotUrl((old) => {
8927
8967
  if (old) URL.revokeObjectURL(old);
@@ -8986,7 +9026,9 @@ function CaptureMode() {
8986
9026
  }
8987
9027
  const r = el.getBoundingClientRect();
8988
9028
  setHover({
8989
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9029
+ // Highlight only what will actually be captured, so the outline is a
9030
+ // promise the screenshot can keep.
9031
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
8990
9032
  selector: getStableSelector(el)
8991
9033
  });
8992
9034
  }
@@ -9033,7 +9075,10 @@ function CaptureMode() {
9033
9075
  const r = el.getBoundingClientRect();
9034
9076
  const sel = {
9035
9077
  kind: "element",
9036
- rect: { top: r.top, left: r.left, width: r.width, height: r.height },
9078
+ // What the browser PAINTS of this element, not its full bounding box —
9079
+ // a row inside a scrolled table is only ever drawn where the table shows
9080
+ // it. See clipToPaintedArea().
9081
+ rect: clipToPaintedArea(el, { top: r.top, left: r.left, width: r.width, height: r.height }),
9037
9082
  selector: getStableSelector(el),
9038
9083
  text: (el.innerText ?? el.textContent ?? "").trim().slice(0, 120),
9039
9084
  tagName: el.tagName.toLowerCase()
@@ -9644,7 +9689,7 @@ function CaptureMode() {
9644
9689
  )
9645
9690
  ] }),
9646
9691
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "qa-space-y-2 qa-p-3", children: [
9647
- /* @__PURE__ */ jsxRuntime.jsx(
9692
+ /* @__PURE__ */ jsxRuntime.jsxs(
9648
9693
  "div",
9649
9694
  {
9650
9695
  className: "qa-flex qa-min-h-16 qa-items-center qa-justify-center qa-rounded-lg qa-border",
@@ -9652,66 +9697,95 @@ function CaptureMode() {
9652
9697
  borderColor: "var(--qa-border-subtle)",
9653
9698
  background: "var(--qa-surface-0)"
9654
9699
  },
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
- ),
9700
+ children: [
9701
+ capturing ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-items-center qa-gap-2 qa-py-4 qa-text-xs qa-text-accent", children: [
9702
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Loader2", size: 16, className: "qa-animate-spin" }),
9703
+ t("capturing")
9704
+ ] }) : shotUrl ? /* @__PURE__ */ jsxRuntime.jsxs(
9705
+ "button",
9706
+ {
9707
+ type: "button",
9708
+ onClick: () => setDrawing(true),
9709
+ title: t("draw_label"),
9710
+ "aria-label": t("draw_label"),
9711
+ className: "qa-relative qa-group qa-tap",
9712
+ style: { background: "transparent", border: "none", padding: 0, cursor: "pointer" },
9713
+ children: [
9714
+ /* @__PURE__ */ jsxRuntime.jsx(
9715
+ "img",
9716
+ {
9717
+ src: shotUrl,
9718
+ alt: "capture",
9719
+ className: "qa-max-h-32 qa-rounded-md"
9720
+ }
9721
+ ),
9722
+ /* @__PURE__ */ jsxRuntime.jsxs(
9723
+ "span",
9724
+ {
9725
+ 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",
9726
+ style: {
9727
+ bottom: 4,
9728
+ insetInlineEnd: 4,
9729
+ background: "var(--qa-surface-1)",
9730
+ color: "var(--qa-ink-hi)",
9731
+ border: "1px solid var(--qa-border-subtle)"
9732
+ },
9733
+ children: [
9734
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Pencil", size: 10 }),
9735
+ t("draw_label")
9736
+ ]
9737
+ }
9738
+ )
9739
+ ]
9740
+ }
9741
+ ) : captureError ? (
9742
+ // The render broke rather than being skipped — offer a retry
9743
+ // against the same selection instead of a dead-end message.
9744
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "qa-flex qa-flex-col qa-items-center qa-gap-2 qa-py-3", children: [
9745
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-text-xs qa-text-red-600", children: t("capture_failed") }),
9676
9746
  /* @__PURE__ */ jsxRuntime.jsxs(
9677
- "span",
9747
+ "button",
9678
9748
  {
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
- },
9749
+ type: "button",
9750
+ onClick: () => selection && void runCapture(selection.rect),
9751
+ 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",
9752
+ style: { background: "transparent", cursor: "pointer" },
9687
9753
  children: [
9688
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Pencil", size: 10 }),
9689
- t("draw_label")
9754
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "RotateCcw", size: 13 }),
9755
+ t("retry")
9690
9756
  ]
9691
9757
  }
9692
9758
  )
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") }),
9700
- /* @__PURE__ */ jsxRuntime.jsxs(
9701
- "button",
9702
- {
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" },
9707
- children: [
9708
- /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "RotateCcw", size: 13 }),
9709
- t("retry")
9710
- ]
9711
- }
9712
- )
9713
- ] })
9714
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") })
9759
+ ] })
9760
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "qa-py-4 qa-text-xs qa-text-slate-400", children: t("no_shot") }),
9761
+ shotUrl && shotEngine === "dom" && exactShots.supported && exactShots.status !== "live" && /* @__PURE__ */ jsxRuntime.jsxs(
9762
+ "span",
9763
+ {
9764
+ "data-qa-redraw-offer": "true",
9765
+ 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",
9766
+ children: [
9767
+ t("redraw_notice"),
9768
+ /* @__PURE__ */ jsxRuntime.jsxs(
9769
+ "button",
9770
+ {
9771
+ type: "button",
9772
+ onClick: () => void (async () => {
9773
+ const granted = await enableExactShots();
9774
+ if (granted && selection) void runCapture(selection.rect);
9775
+ })(),
9776
+ title: t("exact_hint"),
9777
+ 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",
9778
+ style: { background: "transparent", cursor: "pointer" },
9779
+ children: [
9780
+ /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: "Camera", size: 10 }),
9781
+ t("redraw_action")
9782
+ ]
9783
+ }
9784
+ )
9785
+ ]
9786
+ }
9787
+ )
9788
+ ]
9715
9789
  }
9716
9790
  ),
9717
9791
  /* @__PURE__ */ jsxRuntime.jsx(LocationReveal, { target: selection }),
@@ -10005,5 +10079,5 @@ function Qapture({ config }) {
10005
10079
  exports.Qapture = Qapture;
10006
10080
  exports.deleteQaDatabase = deleteQaDatabase;
10007
10081
  exports.initQaStudio = initQaStudio;
10008
- //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10009
- //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10082
+ //# sourceMappingURL=chunk-C5WJFSMN.cjs.map
10083
+ //# sourceMappingURL=chunk-C5WJFSMN.cjs.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 chunkC5WJFSMN_cjs = require('./chunk-C5WJFSMN.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 chunkC5WJFSMN_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 chunkC5WJFSMN_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 chunkC5WJFSMN_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 chunkC5WJFSMN_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-BM3ESR2O.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 chunkC5WJFSMN_cjs = require('./chunk-C5WJFSMN.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 chunkC5WJFSMN_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 chunkC5WJFSMN_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 chunkC5WJFSMN_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-BM3ESR2O.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 chunkC5WJFSMN_cjs = require('./chunk-C5WJFSMN.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 chunkC5WJFSMN_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-BM3ESR2O.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.3",
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",