qapture2 0.7.1 → 0.7.2

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
@@ -315,6 +315,22 @@ rewritten in place, and a render that still throws is retried once with
315
315
  decoration stripped. `npm run modern-css-test` proves it, and proves itself:
316
316
  it first asserts that raw html2canvas *still* dies on the same fixture.
317
317
 
318
+ **v0.7.2 fixed the last of the mis-framing: clicking an element only captured
319
+ the part of it that was on screen.** Dragged regions are clamped into the
320
+ viewport before capture, so they were always safe — but an element *pick* is a
321
+ raw `getBoundingClientRect()`, and a table column, a sidebar, a long form or a
322
+ wide toolbar routinely extends past the fold. You got a fragment. And when the
323
+ element started *above* the viewport, the crop slid down the page to fill its
324
+ height, so the screenshot was the right size showing the wrong content — a
325
+ failure with nothing on screen to reveal it.
326
+
327
+ Captures now render the union of the viewport and the selection, so an element
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.
333
+
318
334
  ### `exact` — opt-in, pixel-for-pixel
319
335
 
320
336
  Uses the Screen Capture API to photograph **this tab's real composited
@@ -2537,6 +2537,8 @@ var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2537
2537
  var MAX_SHOT_EDGE = 1800;
2538
2538
  var WEBP_QUALITY = 0.92;
2539
2539
  var STUCK_ATTR = "data-qa-stuck";
2540
+ var MAX_CAPTURE_EDGE = 4e3;
2541
+ var MAX_RENDER_VIEWPORTS = 4;
2540
2542
  function withTimeout(promise, ms) {
2541
2543
  let timer;
2542
2544
  return Promise.race([
@@ -2702,20 +2704,60 @@ function cropCanvas(full, rect, scale) {
2702
2704
  ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
2703
2705
  return out;
2704
2706
  }
2707
+ function viewportSize() {
2708
+ return {
2709
+ vw: document.documentElement.clientWidth || window.innerWidth,
2710
+ vh: document.documentElement.clientHeight || window.innerHeight
2711
+ };
2712
+ }
2713
+ function intersectViewport(rect) {
2714
+ const { vw, vh } = viewportSize();
2715
+ const left = Math.max(0, rect.left);
2716
+ const top = Math.max(0, rect.top);
2717
+ const right = Math.min(vw, rect.left + rect.width);
2718
+ const bottom = Math.min(vh, rect.top + rect.height);
2719
+ if (right - left < 2 || bottom - top < 2) return null;
2720
+ return { left, top, width: right - left, height: bottom - top };
2721
+ }
2722
+ function planRender(rect, sx, sy, vw, vh) {
2723
+ let left = rect.left + sx;
2724
+ let top = rect.top + sy;
2725
+ let width = rect.width;
2726
+ let height = rect.height;
2727
+ if (width > MAX_CAPTURE_EDGE) {
2728
+ left = Math.max(left, Math.min(sx, left + width - MAX_CAPTURE_EDGE));
2729
+ width = MAX_CAPTURE_EDGE;
2730
+ }
2731
+ if (height > MAX_CAPTURE_EDGE) {
2732
+ top = Math.max(top, Math.min(sy, top + height - MAX_CAPTURE_EDGE));
2733
+ height = MAX_CAPTURE_EDGE;
2734
+ }
2735
+ const x = Math.floor(Math.min(sx, left));
2736
+ const y = Math.floor(Math.min(sy, top));
2737
+ const right = Math.ceil(Math.max(sx + vw, left + width));
2738
+ const bottom = Math.ceil(Math.max(sy + vh, top + height));
2739
+ return {
2740
+ render: { x, y, width: right - x, height: bottom - y },
2741
+ crop: { left: left - x, top: top - y, width, height }
2742
+ };
2743
+ }
2705
2744
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2706
2745
  const { default: html2canvas } = await import('html2canvas');
2707
- const scale = Math.min(window.devicePixelRatio || 1, 2);
2708
- const vw = document.documentElement.clientWidth || window.innerWidth;
2709
- const vh = document.documentElement.clientHeight || window.innerHeight;
2746
+ const { vw, vh } = viewportSize();
2747
+ const plan = planRender(rect, sx, sy, vw, vh);
2748
+ let scale = Math.min(window.devicePixelRatio || 1, 2);
2749
+ const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2750
+ const area = plan.render.width * plan.render.height;
2751
+ if (area > budget) scale *= Math.sqrt(budget / area);
2710
2752
  const marked = markStuckElements();
2711
2753
  try {
2712
2754
  const full = await withTimeout(
2713
2755
  html2canvas(document.body, {
2714
- // Render exactly the current viewport, in document coordinates.
2715
- x: sx,
2716
- y: sy,
2717
- width: vw,
2718
- height: vh,
2756
+ // The box to render, in document coordinates.
2757
+ x: plan.render.x,
2758
+ y: plan.render.y,
2759
+ width: plan.render.width,
2760
+ height: plan.render.height,
2719
2761
  scale,
2720
2762
  useCORS: true,
2721
2763
  allowTaint: true,
@@ -2724,6 +2766,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2724
2766
  // CSS Color 4 treatment the document walk applies.
2725
2767
  backgroundColor: safeBackgroundColor(resolvePageBackground(), FALLBACK_PAGE_BACKGROUND),
2726
2768
  logging: false,
2769
+ // The clone's own scroll + viewport, which is what `position: fixed`
2770
+ // resolves against. It stays the LIVE viewport even when the render
2771
+ // box is bigger, so fixed chrome is drawn where the tester sees it
2772
+ // rather than smeared over the whole render.
2727
2773
  scrollX: sx,
2728
2774
  scrollY: sy,
2729
2775
  windowWidth: vw,
@@ -2737,7 +2783,7 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2737
2783
  HTML2CANVAS_TIMEOUT_MS
2738
2784
  );
2739
2785
  if (!full) return null;
2740
- return cropCanvas(full, rect, scale);
2786
+ return cropCanvas(full, plan.crop, scale);
2741
2787
  } finally {
2742
2788
  unmarkStuckElements(marked);
2743
2789
  }
@@ -2750,8 +2796,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2750
2796
  const sx = scroll?.x ?? window.scrollX;
2751
2797
  const sy = scroll?.y ?? window.scrollY;
2752
2798
  if (prefer !== "dom" && getExactCaptureStatus() === "live") {
2799
+ const visible = intersectViewport(rect);
2800
+ if (!visible) return { status: "empty" };
2753
2801
  try {
2754
- const canvas = await withOverlayHidden(() => grabExactRegion(rect));
2802
+ const canvas = await withOverlayHidden(() => grabExactRegion(visible));
2755
2803
  if (canvas) {
2756
2804
  const blob = await encodeShot(canvas);
2757
2805
  if (blob) return { status: "ok", blob, engine: "exact" };
@@ -9957,5 +10005,5 @@ function Qapture({ config }) {
9957
10005
  exports.Qapture = Qapture;
9958
10006
  exports.deleteQaDatabase = deleteQaDatabase;
9959
10007
  exports.initQaStudio = initQaStudio;
9960
- //# sourceMappingURL=chunk-WAOYPLZA.cjs.map
9961
- //# sourceMappingURL=chunk-WAOYPLZA.cjs.map
10008
+ //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
10009
+ //# sourceMappingURL=chunk-CIFJMD2B.cjs.map
@@ -2530,6 +2530,8 @@ var FALLBACK_PAGE_BACKGROUND = "#ffffff";
2530
2530
  var MAX_SHOT_EDGE = 1800;
2531
2531
  var WEBP_QUALITY = 0.92;
2532
2532
  var STUCK_ATTR = "data-qa-stuck";
2533
+ var MAX_CAPTURE_EDGE = 4e3;
2534
+ var MAX_RENDER_VIEWPORTS = 4;
2533
2535
  function withTimeout(promise, ms) {
2534
2536
  let timer;
2535
2537
  return Promise.race([
@@ -2695,20 +2697,60 @@ function cropCanvas(full, rect, scale) {
2695
2697
  ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
2696
2698
  return out;
2697
2699
  }
2700
+ function viewportSize() {
2701
+ return {
2702
+ vw: document.documentElement.clientWidth || window.innerWidth,
2703
+ vh: document.documentElement.clientHeight || window.innerHeight
2704
+ };
2705
+ }
2706
+ function intersectViewport(rect) {
2707
+ const { vw, vh } = viewportSize();
2708
+ const left = Math.max(0, rect.left);
2709
+ const top = Math.max(0, rect.top);
2710
+ const right = Math.min(vw, rect.left + rect.width);
2711
+ const bottom = Math.min(vh, rect.top + rect.height);
2712
+ if (right - left < 2 || bottom - top < 2) return null;
2713
+ return { left, top, width: right - left, height: bottom - top };
2714
+ }
2715
+ function planRender(rect, sx, sy, vw, vh) {
2716
+ let left = rect.left + sx;
2717
+ let top = rect.top + sy;
2718
+ let width = rect.width;
2719
+ let height = rect.height;
2720
+ if (width > MAX_CAPTURE_EDGE) {
2721
+ left = Math.max(left, Math.min(sx, left + width - MAX_CAPTURE_EDGE));
2722
+ width = MAX_CAPTURE_EDGE;
2723
+ }
2724
+ if (height > MAX_CAPTURE_EDGE) {
2725
+ top = Math.max(top, Math.min(sy, top + height - MAX_CAPTURE_EDGE));
2726
+ height = MAX_CAPTURE_EDGE;
2727
+ }
2728
+ const x = Math.floor(Math.min(sx, left));
2729
+ const y = Math.floor(Math.min(sy, top));
2730
+ const right = Math.ceil(Math.max(sx + vw, left + width));
2731
+ const bottom = Math.ceil(Math.max(sy + vh, top + height));
2732
+ return {
2733
+ render: { x, y, width: right - x, height: bottom - y },
2734
+ crop: { left: left - x, top: top - y, width, height }
2735
+ };
2736
+ }
2698
2737
  async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2699
2738
  const { default: html2canvas } = await import('html2canvas');
2700
- const scale = Math.min(window.devicePixelRatio || 1, 2);
2701
- const vw = document.documentElement.clientWidth || window.innerWidth;
2702
- const vh = document.documentElement.clientHeight || window.innerHeight;
2739
+ const { vw, vh } = viewportSize();
2740
+ const plan = planRender(rect, sx, sy, vw, vh);
2741
+ let scale = Math.min(window.devicePixelRatio || 1, 2);
2742
+ const budget = vw * vh * MAX_RENDER_VIEWPORTS;
2743
+ const area = plan.render.width * plan.render.height;
2744
+ if (area > budget) scale *= Math.sqrt(budget / area);
2703
2745
  const marked = markStuckElements();
2704
2746
  try {
2705
2747
  const full = await withTimeout(
2706
2748
  html2canvas(document.body, {
2707
- // Render exactly the current viewport, in document coordinates.
2708
- x: sx,
2709
- y: sy,
2710
- width: vw,
2711
- height: vh,
2749
+ // The box to render, in document coordinates.
2750
+ x: plan.render.x,
2751
+ y: plan.render.y,
2752
+ width: plan.render.width,
2753
+ height: plan.render.height,
2712
2754
  scale,
2713
2755
  useCORS: true,
2714
2756
  allowTaint: true,
@@ -2717,6 +2759,10 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2717
2759
  // CSS Color 4 treatment the document walk applies.
2718
2760
  backgroundColor: safeBackgroundColor(resolvePageBackground(), FALLBACK_PAGE_BACKGROUND),
2719
2761
  logging: false,
2762
+ // The clone's own scroll + viewport, which is what `position: fixed`
2763
+ // resolves against. It stays the LIVE viewport even when the render
2764
+ // box is bigger, so fixed chrome is drawn where the tester sees it
2765
+ // rather than smeared over the whole render.
2720
2766
  scrollX: sx,
2721
2767
  scrollY: sy,
2722
2768
  windowWidth: vw,
@@ -2730,7 +2776,7 @@ async function captureViaDom(rect, sx, sy, aggressiveColors = false) {
2730
2776
  HTML2CANVAS_TIMEOUT_MS
2731
2777
  );
2732
2778
  if (!full) return null;
2733
- return cropCanvas(full, rect, scale);
2779
+ return cropCanvas(full, plan.crop, scale);
2734
2780
  } finally {
2735
2781
  unmarkStuckElements(marked);
2736
2782
  }
@@ -2743,8 +2789,10 @@ async function captureRegion(rect, scroll, prefer = "auto") {
2743
2789
  const sx = scroll?.x ?? window.scrollX;
2744
2790
  const sy = scroll?.y ?? window.scrollY;
2745
2791
  if (prefer !== "dom" && getExactCaptureStatus() === "live") {
2792
+ const visible = intersectViewport(rect);
2793
+ if (!visible) return { status: "empty" };
2746
2794
  try {
2747
- const canvas = await withOverlayHidden(() => grabExactRegion(rect));
2795
+ const canvas = await withOverlayHidden(() => grabExactRegion(visible));
2748
2796
  if (canvas) {
2749
2797
  const blob = await encodeShot(canvas);
2750
2798
  if (blob) return { status: "ok", blob, engine: "exact" };
@@ -9948,5 +9996,5 @@ function Qapture({ config }) {
9948
9996
  }
9949
9997
 
9950
9998
  export { Qapture, deleteQaDatabase, initQaStudio };
9951
- //# sourceMappingURL=chunk-JSEFC2VP.js.map
9952
- //# sourceMappingURL=chunk-JSEFC2VP.js.map
9999
+ //# sourceMappingURL=chunk-NVFFN7SC.js.map
10000
+ //# sourceMappingURL=chunk-NVFFN7SC.js.map
package/dist/index.cjs CHANGED
@@ -1,24 +1,24 @@
1
1
  'use strict';
2
2
 
3
- var chunkWAOYPLZA_cjs = require('./chunk-WAOYPLZA.cjs');
3
+ var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "QaStudio", {
8
8
  enumerable: true,
9
- get: function () { return chunkWAOYPLZA_cjs.Qapture; }
9
+ get: function () { return chunkCIFJMD2B_cjs.Qapture; }
10
10
  });
11
11
  Object.defineProperty(exports, "Qapture", {
12
12
  enumerable: true,
13
- get: function () { return chunkWAOYPLZA_cjs.Qapture; }
13
+ get: function () { return chunkCIFJMD2B_cjs.Qapture; }
14
14
  });
15
15
  Object.defineProperty(exports, "deleteQaDatabase", {
16
16
  enumerable: true,
17
- get: function () { return chunkWAOYPLZA_cjs.deleteQaDatabase; }
17
+ get: function () { return chunkCIFJMD2B_cjs.deleteQaDatabase; }
18
18
  });
19
19
  Object.defineProperty(exports, "initQaStudio", {
20
20
  enumerable: true,
21
- get: function () { return chunkWAOYPLZA_cjs.initQaStudio; }
21
+ get: function () { return chunkCIFJMD2B_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-JSEFC2VP.js';
1
+ export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-NVFFN7SC.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 chunkWAOYPLZA_cjs = require('./chunk-WAOYPLZA.cjs');
4
+ var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "QaStudio", {
9
9
  enumerable: true,
10
- get: function () { return chunkWAOYPLZA_cjs.Qapture; }
10
+ get: function () { return chunkCIFJMD2B_cjs.Qapture; }
11
11
  });
12
12
  Object.defineProperty(exports, "Qapture", {
13
13
  enumerable: true,
14
- get: function () { return chunkWAOYPLZA_cjs.Qapture; }
14
+ get: function () { return chunkCIFJMD2B_cjs.Qapture; }
15
15
  });
16
16
  Object.defineProperty(exports, "initQaStudio", {
17
17
  enumerable: true,
18
- get: function () { return chunkWAOYPLZA_cjs.initQaStudio; }
18
+ get: function () { return chunkCIFJMD2B_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-JSEFC2VP.js';
2
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-NVFFN7SC.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 chunkWAOYPLZA_cjs = require('./chunk-WAOYPLZA.cjs');
3
+ var chunkCIFJMD2B_cjs = require('./chunk-CIFJMD2B.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 chunkWAOYPLZA_cjs.initQaStudio; }
41
+ get: function () { return chunkCIFJMD2B_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-JSEFC2VP.js';
1
+ export { initQaStudio } from './chunk-NVFFN7SC.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.1",
3
+ "version": "0.7.2",
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",
@@ -89,6 +89,7 @@
89
89
  "standalone-smoke": "node scripts/standalone-smoke.mjs",
90
90
  "browser-test": "node scripts/browser-test.mjs",
91
91
  "capture-accuracy-test": "node scripts/capture-accuracy-test.mjs",
92
+ "element-capture-test": "node scripts/element-capture-test.mjs",
92
93
  "loop-features-test": "node scripts/loop-features-test.mjs",
93
94
  "walk-test": "node scripts/walk-test.mjs",
94
95
  "modern-css-test": "node scripts/modern-css-test.mjs",