@insitue/sdk 0.1.2 → 0.1.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  mountCaptureOnly
3
- } from "./chunk-YUNYW2IC.js";
4
- import "./chunk-X7V2UEBO.js";
3
+ } from "./chunk-PRCHVT5A.js";
4
+ import "./chunk-RYS5Z2BU.js";
5
5
  export {
6
6
  mountCaptureOnly
7
7
  };
@@ -6,8 +6,11 @@ import {
6
6
  d,
7
7
  installRuntimeCollectors,
8
8
  k,
9
+ onDisplayMediaChange,
10
+ retryDisplayMedia,
11
+ stopDisplayMedia,
9
12
  y
10
- } from "./chunk-X7V2UEBO.js";
13
+ } from "./chunk-RYS5Z2BU.js";
11
14
 
12
15
  // src/capture-only.ts
13
16
  var DEFAULT_INGEST = "https://www.insitue.com/api/v1/capture";
@@ -68,6 +71,32 @@ function CaptureOnlyApp(props) {
68
71
  const [phase, setPhase] = d("idle");
69
72
  const [bundle, setBundle] = d(null);
70
73
  const [note, setNote] = d("");
74
+ const [tabCaptureActive, setTabCaptureActive] = d(false);
75
+ const [tabCaptureDenied, setTabCaptureDenied] = d(false);
76
+ y(() => {
77
+ return onDisplayMediaChange((active, reason) => {
78
+ setTabCaptureActive(active);
79
+ if (reason === "denied") setTabCaptureDenied(true);
80
+ if (reason === "granted") setTabCaptureDenied(false);
81
+ });
82
+ }, []);
83
+ const retryWithPixelPerfect = async () => {
84
+ if (!bundle?.target?.selector) {
85
+ await retryDisplayMedia();
86
+ return;
87
+ }
88
+ const granted = await retryDisplayMedia();
89
+ if (granted) {
90
+ setPhase("picking");
91
+ const sel = await beginPick("element").catch(() => null);
92
+ if (sel) {
93
+ setBundle(await buildBundle(sel));
94
+ setPhase("compose");
95
+ } else {
96
+ setPhase("compose");
97
+ }
98
+ }
99
+ };
71
100
  const sink = new IssueTrackerSink(async (draft) => {
72
101
  globalThis.__insitu_capture__ = {
73
102
  title: draft.title,
@@ -235,6 +264,56 @@ function CaptureOnlyApp(props) {
235
264
  },
236
265
  "Screenshot unavailable \u2014 sending the rest."
237
266
  ) : null,
267
+ // Nudge: the screenshot is structurally OK but some content
268
+ // couldn't be embedded (non-CORS img / video / canvas). Offer
269
+ // a one-tap upgrade to pixel-perfect mode + re-pick.
270
+ bundle?.screenshot?.qualityNote && !tabCaptureActive ? k(
271
+ "div",
272
+ {
273
+ style: `display:flex;align-items:center;gap:8px;padding:9px 11px;background:#fff7ed;border:1px solid #fbd9b1;color:#8a4b00;border-radius:10px;font-size:12px;margin-bottom:12px`
274
+ },
275
+ [
276
+ k(
277
+ "span",
278
+ { style: "flex:1" },
279
+ "Some content didn't capture cleanly. Enable tab capture for a pixel-perfect screenshot."
280
+ ),
281
+ k(
282
+ "button",
283
+ {
284
+ onClick: () => void retryWithPixelPerfect(),
285
+ style: `all:unset;cursor:pointer;color:#5751e6;font-weight:600;padding:2px 8px;border:1px solid #c5c2ff;border-radius:6px;background:#fff`
286
+ },
287
+ "Enable"
288
+ )
289
+ ]
290
+ ) : null,
291
+ // Active badge — tells the user why the browser's red "tab
292
+ // sharing" indicator is on + lets them stop it.
293
+ tabCaptureActive ? k(
294
+ "div",
295
+ {
296
+ style: `display:flex;align-items:center;gap:8px;padding:7px 11px;background:#ecfdf5;border:1px solid #b6e6cf;color:#117a52;border-radius:10px;font-size:11.5px;margin-bottom:12px`
297
+ },
298
+ [
299
+ k("span", {
300
+ style: "width:8px;height:8px;border-radius:50%;background:#117a52;box-shadow:0 0 6px #117a52"
301
+ }),
302
+ k(
303
+ "span",
304
+ { style: "flex:1" },
305
+ "Tab capture active \u2014 screenshots are pixel-perfect."
306
+ ),
307
+ k(
308
+ "button",
309
+ {
310
+ onClick: () => stopDisplayMedia("user"),
311
+ style: `all:unset;cursor:pointer;color:#117a52;font-weight:600;padding:2px 6px`
312
+ },
313
+ "Stop"
314
+ )
315
+ ]
316
+ ) : null,
238
317
  k("textarea", {
239
318
  value: note,
240
319
  rows: 3,
@@ -582,7 +582,7 @@ function resolveTarget(el) {
582
582
  }
583
583
  return source === void 0 ? { confidence, componentStack, selector } : { source, confidence, componentStack, selector };
584
584
  }
585
- var CAPTURE_SCHEMA_VERSION = 2;
585
+ var CAPTURE_SCHEMA_VERSION = 3;
586
586
  var PROTOCOL_VERSION = 4;
587
587
  function toIssueDraft(bundle) {
588
588
  const t3 = bundle.target;
@@ -597,7 +597,7 @@ function toIssueDraft(bundle) {
597
597
  `**Viewport:** ${bundle.viewport.w}\xD7${bundle.viewport.h}${bundle.viewport.breakpoint ? ` (${bundle.viewport.breakpoint})` : ""}`,
598
598
  `**Tailwind:** ${bundle.tailwindClasses.join(" ") || "\u2014"}`,
599
599
  `**Runtime:** ${bundle.runtime.console.length} log \xB7 ${bundle.runtime.network.length} net \xB7 ${errs} err`,
600
- `**Screenshot:** ${bundle.screenshot ? "attached" : bundle.screenshotUnavailable ? `unavailable \u2014 ${bundle.screenshotUnavailable}` : "\u2014"}`,
600
+ `**Screenshot:** ${bundle.screenshot ? `attached` + (bundle.screenshot.source ? ` (${bundle.screenshot.source})` : "") + (bundle.screenshot.qualityNote ? ` \u2014 ${bundle.screenshot.qualityNote}` : "") : bundle.screenshotUnavailable ? `unavailable \u2014 ${bundle.screenshotUnavailable}` : "\u2014"}`,
601
601
  bundle.userNote ? `
602
602
  > ${bundle.userNote}` : "",
603
603
  `
@@ -802,6 +802,53 @@ function beginPick(mode = "element") {
802
802
  });
803
803
  }
804
804
 
805
+ // src/capture-settings.ts
806
+ var DEFAULT_SETTINGS = {
807
+ alwaysPixelPerfect: false
808
+ };
809
+ function storageKey() {
810
+ if (typeof location === "undefined") return "insitu:capture-settings";
811
+ return `insitu:capture-settings:${location.host}`;
812
+ }
813
+ var cached = null;
814
+ function getCaptureSettings() {
815
+ if (cached) return cached;
816
+ if (typeof localStorage === "undefined") {
817
+ cached = { ...DEFAULT_SETTINGS };
818
+ return cached;
819
+ }
820
+ try {
821
+ const raw = localStorage.getItem(storageKey());
822
+ if (!raw) {
823
+ cached = { ...DEFAULT_SETTINGS };
824
+ return cached;
825
+ }
826
+ const parsed = JSON.parse(raw);
827
+ cached = { ...DEFAULT_SETTINGS, ...parsed };
828
+ return cached;
829
+ } catch {
830
+ cached = { ...DEFAULT_SETTINGS };
831
+ return cached;
832
+ }
833
+ }
834
+ function setCaptureSettings(patch) {
835
+ const next = { ...getCaptureSettings(), ...patch };
836
+ cached = next;
837
+ if (typeof localStorage !== "undefined") {
838
+ try {
839
+ localStorage.setItem(storageKey(), JSON.stringify(next));
840
+ } catch {
841
+ }
842
+ }
843
+ for (const l3 of listeners) l3(next);
844
+ }
845
+ var listeners = /* @__PURE__ */ new Set();
846
+ function onCaptureSettingsChange(l3) {
847
+ listeners.add(l3);
848
+ l3(getCaptureSettings());
849
+ return () => listeners.delete(l3);
850
+ }
851
+
805
852
  // ../../node_modules/.pnpm/html-to-image@1.11.13/node_modules/html-to-image/es/util.js
806
853
  function resolveUrl(url, baseUrl) {
807
854
  if (url.match(/^[a-z]+:\/\//i)) {
@@ -1604,30 +1651,9 @@ function crossOrigin(url) {
1604
1651
  return false;
1605
1652
  }
1606
1653
  }
1607
- function crossOriginMediaReason(root) {
1608
- const els = [root, ...root.querySelectorAll("*")];
1609
- for (const el of els) {
1610
- if (el instanceof HTMLImageElement) {
1611
- if (crossOrigin(el.currentSrc || el.src) && el.crossOrigin == null) {
1612
- const host = (() => {
1613
- try {
1614
- return new URL(el.currentSrc || el.src).host;
1615
- } catch {
1616
- return "cross-origin";
1617
- }
1618
- })();
1619
- return `cross-origin <img> (${host})`;
1620
- }
1621
- }
1622
- const bg = getComputedStyle(el).backgroundImage;
1623
- const m3 = bg && bg !== "none" ? /url\(["']?([^"')]+)["']?\)/.exec(bg) : null;
1624
- if (m3 && crossOrigin(m3[1])) return "cross-origin CSS background image";
1625
- if ((el instanceof HTMLVideoElement || el instanceof HTMLCanvasElement) && crossOrigin(el.src)) {
1626
- return `cross-origin <${el.tagName.toLowerCase()}>`;
1627
- }
1628
- }
1629
- return null;
1630
- }
1654
+ var IMAGE_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
1655
+ `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect width="32" height="32" fill="#e8e8e8"/><path d="M0 0 L32 32 M32 0 L0 32" stroke="#b0b0b0" stroke-width="1.5"/></svg>`
1656
+ );
1631
1657
  function findContextAncestor(el) {
1632
1658
  const minW = 420;
1633
1659
  const minH = 140;
@@ -1653,17 +1679,10 @@ async function renderViewportCrop(cropRect, pixelRatio) {
1653
1679
  pixelRatio,
1654
1680
  cacheBust: true,
1655
1681
  backgroundColor,
1656
- filter: (n2) => {
1657
- if (n2 instanceof Element && n2.closest?.("#insitu-root, [data-insitu-layer]")) {
1658
- return false;
1659
- }
1660
- if (n2 instanceof HTMLImageElement) {
1661
- if (crossOrigin(n2.currentSrc || n2.src) && n2.crossOrigin == null) {
1662
- return false;
1663
- }
1664
- }
1665
- return true;
1666
- }
1682
+ imagePlaceholder: IMAGE_PLACEHOLDER,
1683
+ // Only filter out our own overlay layers — leave cross-origin
1684
+ // <img>s in place so embedImages can fetch+inline them.
1685
+ filter: (n2) => !(n2 instanceof Element && n2.closest?.("#insitu-root, [data-insitu-layer]"))
1667
1686
  });
1668
1687
  const sx = window.scrollX;
1669
1688
  const sy = window.scrollY;
@@ -1683,8 +1702,240 @@ async function renderViewportCrop(cropRect, pixelRatio) {
1683
1702
  out.width,
1684
1703
  out.height
1685
1704
  );
1705
+ if (looksBlankUniform(ctx, out.width, out.height)) {
1706
+ return null;
1707
+ }
1686
1708
  return out.toDataURL("image/png");
1687
1709
  }
1710
+ function looksBlankUniform(ctx, w3, h3) {
1711
+ if (w3 < 4 || h3 < 4) return false;
1712
+ const samples = [];
1713
+ for (let i3 = 0; i3 < 4; i3++) {
1714
+ for (let j3 = 0; j3 < 4; j3++) {
1715
+ const x2 = Math.floor(w3 * (i3 + 0.5) / 4);
1716
+ const y3 = Math.floor(h3 * (j3 + 0.5) / 4);
1717
+ try {
1718
+ const px2 = ctx.getImageData(x2, y3, 1, 1).data;
1719
+ samples.push(`${px2[0]},${px2[1]},${px2[2]},${px2[3]}`);
1720
+ } catch {
1721
+ return true;
1722
+ }
1723
+ }
1724
+ }
1725
+ return new Set(samples).size === 1;
1726
+ }
1727
+ function assessCaptureQuality(cropRect) {
1728
+ const out = {
1729
+ unembeddableImages: 0,
1730
+ hasVideo: false,
1731
+ hasCanvas: false
1732
+ };
1733
+ const all = document.querySelectorAll("img, video, canvas");
1734
+ for (const el of all) {
1735
+ if (el instanceof Element && el.closest?.("#insitu-root, [data-insitu-layer]")) {
1736
+ continue;
1737
+ }
1738
+ const r3 = el.getBoundingClientRect();
1739
+ const overlaps = r3.right >= cropRect.x && r3.left <= cropRect.x + cropRect.width && r3.bottom >= cropRect.y && r3.top <= cropRect.y + cropRect.height;
1740
+ if (!overlaps) continue;
1741
+ if (el instanceof HTMLImageElement) {
1742
+ const src = el.currentSrc || el.src;
1743
+ if (!src) continue;
1744
+ const browserLoaded = el.complete && el.naturalWidth > 0;
1745
+ const corsSafe = !crossOrigin(src) || el.crossOrigin != null;
1746
+ if (!browserLoaded || !corsSafe) {
1747
+ out.unembeddableImages++;
1748
+ }
1749
+ } else if (el instanceof HTMLVideoElement) {
1750
+ if (r3.width > 0 && r3.height > 0) out.hasVideo = true;
1751
+ } else if (el instanceof HTMLCanvasElement) {
1752
+ if (r3.width > 0 && r3.height > 0) out.hasCanvas = true;
1753
+ }
1754
+ }
1755
+ return out;
1756
+ }
1757
+ function describeImperfection(q2) {
1758
+ const parts = [];
1759
+ if (q2.unembeddableImages > 0) {
1760
+ parts.push(
1761
+ `${q2.unembeddableImages} non-CORS image${q2.unembeddableImages > 1 ? "s" : ""}`
1762
+ );
1763
+ }
1764
+ if (q2.hasVideo) parts.push("video frame");
1765
+ if (q2.hasCanvas) parts.push("canvas content");
1766
+ return parts.join(" + ");
1767
+ }
1768
+ var displayMediaState = {
1769
+ stream: null,
1770
+ trackEndedHandler: null,
1771
+ idleTimer: null,
1772
+ deniedAt: null
1773
+ };
1774
+ var IDLE_MS = 9e4;
1775
+ var displayMediaListeners = /* @__PURE__ */ new Set();
1776
+ function onDisplayMediaChange(l3) {
1777
+ displayMediaListeners.add(l3);
1778
+ l3(displayMediaState.stream != null);
1779
+ return () => displayMediaListeners.delete(l3);
1780
+ }
1781
+ function notifyDisplayMedia(reason) {
1782
+ const active = displayMediaState.stream != null;
1783
+ for (const l3 of displayMediaListeners) l3(active, reason);
1784
+ }
1785
+ function stopDisplayMedia(reason = "stopped") {
1786
+ if (displayMediaState.stream) {
1787
+ for (const t3 of displayMediaState.stream.getTracks()) t3.stop();
1788
+ }
1789
+ if (displayMediaState.idleTimer) clearTimeout(displayMediaState.idleTimer);
1790
+ if (displayMediaState.trackEndedHandler && displayMediaState.stream) {
1791
+ for (const t3 of displayMediaState.stream.getTracks()) {
1792
+ t3.removeEventListener("ended", displayMediaState.trackEndedHandler);
1793
+ }
1794
+ }
1795
+ displayMediaState.stream = null;
1796
+ displayMediaState.trackEndedHandler = null;
1797
+ displayMediaState.idleTimer = null;
1798
+ notifyDisplayMedia(reason);
1799
+ }
1800
+ function bumpIdleTimer() {
1801
+ if (displayMediaState.idleTimer) clearTimeout(displayMediaState.idleTimer);
1802
+ displayMediaState.idleTimer = setTimeout(
1803
+ () => stopDisplayMedia("idle"),
1804
+ IDLE_MS
1805
+ );
1806
+ }
1807
+ function supportsDisplayMedia() {
1808
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function";
1809
+ }
1810
+ async function ensureDisplayMediaStream() {
1811
+ if (!supportsDisplayMedia()) return null;
1812
+ if (displayMediaState.stream) {
1813
+ bumpIdleTimer();
1814
+ return displayMediaState.stream;
1815
+ }
1816
+ if (displayMediaState.deniedAt && Date.now() - displayMediaState.deniedAt < 6e4) {
1817
+ return null;
1818
+ }
1819
+ try {
1820
+ const stream = await navigator.mediaDevices.getDisplayMedia({
1821
+ // `displaySurface: 'browser'` + `preferCurrentTab: true` makes
1822
+ // Chrome/Edge default-select the current tab in the prompt.
1823
+ // Other browsers ignore the hints; user still picks manually.
1824
+ video: {
1825
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1826
+ displaySurface: "browser"
1827
+ },
1828
+ audio: false,
1829
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1830
+ preferCurrentTab: true
1831
+ });
1832
+ displayMediaState.stream = stream;
1833
+ displayMediaState.deniedAt = null;
1834
+ const handler = () => stopDisplayMedia("track-ended");
1835
+ for (const t3 of stream.getTracks()) t3.addEventListener("ended", handler);
1836
+ displayMediaState.trackEndedHandler = handler;
1837
+ bumpIdleTimer();
1838
+ notifyDisplayMedia("granted");
1839
+ return stream;
1840
+ } catch {
1841
+ displayMediaState.deniedAt = Date.now();
1842
+ notifyDisplayMedia("denied");
1843
+ return null;
1844
+ }
1845
+ }
1846
+ async function retryDisplayMedia() {
1847
+ displayMediaState.deniedAt = null;
1848
+ const s3 = await ensureDisplayMediaStream();
1849
+ return s3 != null;
1850
+ }
1851
+ function hideOverlayLayersBriefly() {
1852
+ const id = "insitu-capture-hide";
1853
+ const style = document.createElement("style");
1854
+ style.id = id;
1855
+ style.textContent = `
1856
+ #insitu-root, [data-insitu-layer] { visibility: hidden !important; }
1857
+ `;
1858
+ document.head.appendChild(style);
1859
+ return () => {
1860
+ style.remove();
1861
+ };
1862
+ }
1863
+ async function tryGrabViaDisplayMedia(cropRect, pixelRatio) {
1864
+ const wasActive = displayMediaState.stream != null;
1865
+ const stream = await ensureDisplayMediaStream();
1866
+ if (!stream) return null;
1867
+ const fresh = !wasActive;
1868
+ bumpIdleTimer();
1869
+ const restoreOverlay = hideOverlayLayersBriefly();
1870
+ await new Promise(
1871
+ (r3) => requestAnimationFrame(() => requestAnimationFrame(() => r3()))
1872
+ );
1873
+ try {
1874
+ const track = stream.getVideoTracks()[0];
1875
+ if (!track) return null;
1876
+ let bitmap = null;
1877
+ const Ctor = window.ImageCapture;
1878
+ if (Ctor) {
1879
+ bitmap = await new Ctor(track).grabFrame();
1880
+ } else {
1881
+ bitmap = await grabFrameViaVideo(stream);
1882
+ }
1883
+ if (!bitmap) return null;
1884
+ const frameW = bitmap.width;
1885
+ const frameH = bitmap.height;
1886
+ const scaleX = frameW / window.innerWidth;
1887
+ const scaleY = frameH / window.innerHeight;
1888
+ const sx = Math.max(0, Math.round(cropRect.x * scaleX));
1889
+ const sy = Math.max(0, Math.round(cropRect.y * scaleY));
1890
+ const sw = Math.min(frameW - sx, Math.round(cropRect.width * scaleX));
1891
+ const sh = Math.min(frameH - sy, Math.round(cropRect.height * scaleY));
1892
+ const out = document.createElement("canvas");
1893
+ out.width = Math.max(1, Math.round(cropRect.width * pixelRatio));
1894
+ out.height = Math.max(1, Math.round(cropRect.height * pixelRatio));
1895
+ const ctx = out.getContext("2d");
1896
+ if (!ctx) return null;
1897
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, out.width, out.height);
1898
+ bitmap.close?.();
1899
+ return { dataUrl: out.toDataURL("image/png"), fresh };
1900
+ } finally {
1901
+ restoreOverlay();
1902
+ }
1903
+ }
1904
+ async function grabFrameViaVideo(stream) {
1905
+ const video = document.createElement("video");
1906
+ video.srcObject = stream;
1907
+ video.muted = true;
1908
+ video.playsInline = true;
1909
+ video.style.position = "fixed";
1910
+ video.style.pointerEvents = "none";
1911
+ video.style.opacity = "0";
1912
+ video.style.width = "1px";
1913
+ video.style.height = "1px";
1914
+ document.body.appendChild(video);
1915
+ try {
1916
+ await video.play().catch(() => void 0);
1917
+ await new Promise((resolve, reject) => {
1918
+ const onReady = () => {
1919
+ video.removeEventListener("loadeddata", onReady);
1920
+ resolve();
1921
+ };
1922
+ video.addEventListener("loadeddata", onReady, { once: true });
1923
+ setTimeout(() => reject(new Error("video timeout")), 2e3);
1924
+ });
1925
+ const tmp = document.createElement("canvas");
1926
+ tmp.width = video.videoWidth;
1927
+ tmp.height = video.videoHeight;
1928
+ const ctx = tmp.getContext("2d");
1929
+ if (!ctx) throw new Error("no 2d ctx");
1930
+ ctx.drawImage(video, 0, 0);
1931
+ return await createImageBitmap(tmp);
1932
+ } finally {
1933
+ video.remove();
1934
+ }
1935
+ }
1936
+ if (typeof window !== "undefined") {
1937
+ window.addEventListener("pagehide", () => stopDisplayMedia("pagehide"));
1938
+ }
1688
1939
  function elementFor(sel) {
1689
1940
  if (sel.mode === "element") return sel.pointerPath?.[0] ?? null;
1690
1941
  if (sel.rect) {
@@ -1698,6 +1949,7 @@ async function buildBundle(sel) {
1698
1949
  const el = elementFor(sel);
1699
1950
  const rt = runtimeSnapshot();
1700
1951
  const dpr = window.devicePixelRatio || 1;
1952
+ const settings = getCaptureSettings();
1701
1953
  let screenshot;
1702
1954
  let screenshotUnavailable;
1703
1955
  if (el instanceof HTMLElement) {
@@ -1716,32 +1968,68 @@ async function buildBundle(sel) {
1716
1968
  el.style.outline = "3px solid #ff6b00";
1717
1969
  el.style.outlineOffset = "2px";
1718
1970
  try {
1719
- const dataUrl = await renderViewportCrop(
1720
- cropRect,
1721
- // Cap pixel ratio for full-document rasterise — 2× of a
1722
- // long page is enough to blow per-tab canvas memory caps
1723
- // on some browsers (and 1.5× still looks crisp).
1724
- Math.min(dpr, 1.5)
1725
- );
1726
- if (!dataUrl || dataUrl.length < 1024) {
1727
- const taint = crossOriginMediaReason(el);
1728
- screenshotUnavailable = taint ? `${taint} \u2014 can't rasterise in-browser` : "rasterise produced an empty image";
1729
- } else {
1971
+ const skipLayer1 = settings.alwaysPixelPerfect;
1972
+ let layer1Result = null;
1973
+ let quality = null;
1974
+ if (!skipLayer1) {
1975
+ layer1Result = await renderViewportCrop(
1976
+ cropRect,
1977
+ Math.min(dpr, 1.5)
1978
+ );
1979
+ quality = assessCaptureQuality(cropRect);
1980
+ }
1981
+ const imperfect = !layer1Result || quality != null && (quality.unembeddableImages > 0 || quality.hasVideo || quality.hasCanvas);
1982
+ if (imperfect || skipLayer1) {
1983
+ const grab = await tryGrabViaDisplayMedia(
1984
+ cropRect,
1985
+ Math.min(dpr, 2)
1986
+ );
1987
+ if (grab) {
1988
+ screenshot = {
1989
+ mime: "image/png",
1990
+ dataUrl: grab.dataUrl,
1991
+ bounds: {
1992
+ x: cropRect.x,
1993
+ y: cropRect.y,
1994
+ width: cropRect.width,
1995
+ height: cropRect.height
1996
+ },
1997
+ source: "display-media"
1998
+ };
1999
+ } else if (layer1Result) {
2000
+ const reason = quality ? describeImperfection(quality) : "non-CORS content";
2001
+ screenshot = {
2002
+ mime: "image/png",
2003
+ dataUrl: layer1Result,
2004
+ bounds: {
2005
+ x: cropRect.x,
2006
+ y: cropRect.y,
2007
+ width: cropRect.width,
2008
+ height: cropRect.height
2009
+ },
2010
+ source: "rasterise",
2011
+ qualityNote: `${reason} couldn't be embedded \u2014 grant tab capture for pixel-perfect screenshots`
2012
+ };
2013
+ } else {
2014
+ screenshotUnavailable = supportsDisplayMedia() ? "rasterise failed \u2014 grant tab capture for pixel-perfect screenshots" : "rasterise failed and tab capture unsupported in this browser";
2015
+ }
2016
+ } else if (layer1Result) {
1730
2017
  screenshot = {
1731
2018
  mime: "image/png",
1732
- dataUrl,
1733
- // Bounds describe the SCREENSHOT (the crop region) so
1734
- // the dashboard knows what slice of viewport this is.
2019
+ dataUrl: layer1Result,
1735
2020
  bounds: {
1736
2021
  x: cropRect.x,
1737
2022
  y: cropRect.y,
1738
2023
  width: cropRect.width,
1739
2024
  height: cropRect.height
1740
- }
2025
+ },
2026
+ source: "rasterise"
1741
2027
  };
2028
+ } else {
2029
+ screenshotUnavailable = "rasterise produced an empty image";
1742
2030
  }
1743
- } catch {
1744
- screenshotUnavailable = "rasterise failed";
2031
+ } catch (err) {
2032
+ screenshotUnavailable = err instanceof Error ? `rasterise failed: ${err.message}` : "rasterise failed";
1745
2033
  } finally {
1746
2034
  el.style.outline = orig.outline;
1747
2035
  el.style.outlineOffset = orig.outlineOffset;
@@ -1784,5 +2072,11 @@ export {
1784
2072
  installRuntimeCollectors,
1785
2073
  runtimeErrorCount,
1786
2074
  beginPick,
2075
+ getCaptureSettings,
2076
+ setCaptureSettings,
2077
+ onCaptureSettingsChange,
2078
+ onDisplayMediaChange,
2079
+ stopDisplayMedia,
2080
+ retryDisplayMedia,
1787
2081
  buildBundle
1788
2082
  };
@@ -5,11 +5,17 @@ import {
5
5
  beginPick,
6
6
  buildBundle,
7
7
  d,
8
+ getCaptureSettings,
8
9
  installRuntimeCollectors,
9
10
  k,
11
+ onCaptureSettingsChange,
12
+ onDisplayMediaChange,
13
+ retryDisplayMedia,
10
14
  runtimeErrorCount,
15
+ setCaptureSettings,
16
+ stopDisplayMedia,
11
17
  y
12
- } from "./chunk-X7V2UEBO.js";
18
+ } from "./chunk-RYS5Z2BU.js";
13
19
 
14
20
  // src/client.ts
15
21
  var CompanionClient = class {
@@ -238,6 +244,11 @@ function App(props) {
238
244
  const [showCtx, setShowCtx] = d(false);
239
245
  const [showSettings, setShowSettings] = d(false);
240
246
  const [autoApply, setAutoApply] = d(false);
247
+ const [captureSettings, setCaptureSettingsState] = d(
248
+ getCaptureSettings()
249
+ );
250
+ const [displayMediaActive, setDisplayMediaActive] = d(false);
251
+ const [displayMediaDenied, setDisplayMediaDenied] = d(false);
241
252
  const [agentReady, setAgentReady] = d(null);
242
253
  const [agentNote, setAgentNote] = d("");
243
254
  const [chatInput, setChatInput] = d("");
@@ -433,6 +444,18 @@ function App(props) {
433
444
  const el = threadRef.current;
434
445
  if (el) el.scrollTop = el.scrollHeight;
435
446
  }, [messages, changes, turnBusy, activity]);
447
+ y(() => {
448
+ const off1 = onDisplayMediaChange((active, reason) => {
449
+ setDisplayMediaActive(active);
450
+ if (reason === "denied") setDisplayMediaDenied(true);
451
+ if (reason === "granted") setDisplayMediaDenied(false);
452
+ });
453
+ const off2 = onCaptureSettingsChange((s) => setCaptureSettingsState(s));
454
+ return () => {
455
+ off1();
456
+ off2();
457
+ };
458
+ }, []);
436
459
  y(() => {
437
460
  const onKey = (ev) => {
438
461
  const meta = ev.metaKey || ev.ctrlKey;
@@ -964,8 +987,79 @@ ${resolved.snippet}`
964
987
  "div",
965
988
  { style: `color:${muted};margin-top:4px` },
966
989
  "Writes proposed changes immediately. Still checkpointed & undoable; no manual gate. Resets on reload."
990
+ ),
991
+ k(
992
+ "label",
993
+ {
994
+ style: "display:flex;gap:8px;align-items:center;cursor:pointer;color:#ececef;margin-top:10px"
995
+ },
996
+ [
997
+ k("input", {
998
+ type: "checkbox",
999
+ checked: captureSettings.alwaysPixelPerfect,
1000
+ onChange: (ev) => setCaptureSettings({
1001
+ alwaysPixelPerfect: ev.target.checked
1002
+ })
1003
+ }),
1004
+ k("span", {}, "Always pixel-perfect screenshots")
1005
+ ]
1006
+ ),
1007
+ k(
1008
+ "div",
1009
+ { style: `color:${muted};margin-top:4px` },
1010
+ "Skips the silent rasterise path and always uses tab capture. One permission per session; every screenshot is OS-pixel accurate."
967
1011
  )
968
1012
  ]) : null;
1013
+ const captureActivePill = displayMediaActive ? k(
1014
+ "div",
1015
+ {
1016
+ style: "display:flex;align-items:center;gap:8px;padding:6px 10px;margin:6px 0;border-radius:4px;background:#1a2a1f;border:1px solid #2f5040;color:#9fe7b8;font-size:11px"
1017
+ },
1018
+ [
1019
+ k(
1020
+ "span",
1021
+ { style: "display:inline-flex;align-items:center;gap:6px" },
1022
+ [
1023
+ k("span", {
1024
+ style: "width:8px;height:8px;border-radius:50%;background:#2fd16b;box-shadow:0 0 6px #2fd16b"
1025
+ }),
1026
+ k("span", {}, "Tab capture active")
1027
+ ]
1028
+ ),
1029
+ k(
1030
+ "button",
1031
+ {
1032
+ style: `${btn};margin-left:auto`,
1033
+ onClick: () => stopDisplayMedia("user"),
1034
+ title: "Stop sharing this tab"
1035
+ },
1036
+ "Stop"
1037
+ )
1038
+ ]
1039
+ ) : null;
1040
+ const captureDeniedNudge = displayMediaDenied && !displayMediaActive ? k(
1041
+ "div",
1042
+ {
1043
+ style: "display:flex;align-items:center;gap:8px;padding:6px 10px;margin:6px 0;border-radius:4px;background:#2a1f1a;border:1px solid #5a3a2a;color:#e8c69f;font-size:11px"
1044
+ },
1045
+ [
1046
+ k(
1047
+ "span",
1048
+ { style: "flex:1" },
1049
+ "Screenshot missed some cross-origin content. Grant tab capture for pixel-perfect captures."
1050
+ ),
1051
+ k(
1052
+ "button",
1053
+ {
1054
+ style: btn,
1055
+ onClick: () => {
1056
+ void retryDisplayMedia();
1057
+ }
1058
+ },
1059
+ "Enable"
1060
+ )
1061
+ ]
1062
+ ) : null;
969
1063
  const panel = open ? k(
970
1064
  "div",
971
1065
  {
@@ -1021,6 +1115,8 @@ ${resolved.snippet}`
1021
1115
  ]
1022
1116
  ),
1023
1117
  settings,
1118
+ captureActivePill,
1119
+ captureDeniedNudge,
1024
1120
  ctx,
1025
1121
  conversation,
1026
1122
  timeline,
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  mountCaptureOnly
3
- } from "./chunk-YUNYW2IC.js";
3
+ } from "./chunk-PRCHVT5A.js";
4
4
  import {
5
5
  mountInSitue
6
- } from "./chunk-VMBBJKFF.js";
7
- import "./chunk-X7V2UEBO.js";
6
+ } from "./chunk-VWPAKOUW.js";
7
+ import "./chunk-RYS5Z2BU.js";
8
8
 
9
9
  // src/InSitue.tsx
10
10
  import { useEffect } from "react";
package/dist/overlay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  mountInSitue
3
- } from "./chunk-VMBBJKFF.js";
4
- import "./chunk-X7V2UEBO.js";
3
+ } from "./chunk-VWPAKOUW.js";
4
+ import "./chunk-RYS5Z2BU.js";
5
5
  export {
6
6
  mountInSitue
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insitue/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "InSitue capture SDK — drop one snippet into your deployed app; your users point at a bug, InSitue opens a verified pull request.",
5
5
  "license": "MIT",
6
6
  "type": "module",