@insitue/sdk 0.1.2 → 0.1.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  mountCaptureOnly
3
- } from "./chunk-YUNYW2IC.js";
4
- import "./chunk-X7V2UEBO.js";
3
+ } from "./chunk-TTH4NBFA.js";
4
+ import "./chunk-65BGOX2M.js";
5
5
  export {
6
6
  mountCaptureOnly
7
7
  };
@@ -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)) {
@@ -1594,39 +1641,61 @@ async function toCanvas(node, options = {}) {
1594
1641
  }
1595
1642
 
1596
1643
  // src/capture.ts
1597
- function crossOrigin(url) {
1598
- if (!url || url.startsWith("data:") || url.startsWith("blob:")) {
1599
- return false;
1600
- }
1601
- try {
1602
- return new URL(url, location.href).origin !== location.origin;
1603
- } catch {
1604
- return false;
1605
- }
1606
- }
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})`;
1644
+ var IMAGE_PLACEHOLDER = "data:image/svg+xml;base64," + (typeof btoa !== "undefined" ? btoa(
1645
+ '<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>'
1646
+ ) : "");
1647
+ async function preResolveImages() {
1648
+ const restorations = [];
1649
+ const failedImages = /* @__PURE__ */ new Set();
1650
+ const images = Array.from(
1651
+ document.querySelectorAll("img")
1652
+ ).filter(
1653
+ (img) => !img.closest?.("#insitu-root, [data-insitu-layer]")
1654
+ );
1655
+ await Promise.all(
1656
+ images.map(async (img) => {
1657
+ const srcToFetch = img.currentSrc || img.src;
1658
+ if (!srcToFetch || srcToFetch.startsWith("data:") || srcToFetch.startsWith("blob:")) {
1659
+ return;
1620
1660
  }
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;
1661
+ try {
1662
+ const res = await fetch(srcToFetch, { cache: "force-cache" });
1663
+ if (!res.ok) {
1664
+ failedImages.add(img);
1665
+ return;
1666
+ }
1667
+ const blob = await res.blob();
1668
+ const dataUrl = await new Promise((resolve, reject) => {
1669
+ const reader = new FileReader();
1670
+ reader.onload = () => resolve(reader.result);
1671
+ reader.onerror = () => reject(reader.error);
1672
+ reader.readAsDataURL(blob);
1673
+ });
1674
+ const origSrc = img.getAttribute("src");
1675
+ const origSrcset = img.getAttribute("srcset");
1676
+ restorations.push(() => {
1677
+ if (origSrc != null) img.setAttribute("src", origSrc);
1678
+ else img.removeAttribute("src");
1679
+ if (origSrcset != null) img.setAttribute("srcset", origSrcset);
1680
+ else img.removeAttribute("srcset");
1681
+ });
1682
+ img.removeAttribute("srcset");
1683
+ img.src = dataUrl;
1684
+ try {
1685
+ await img.decode();
1686
+ } catch {
1687
+ }
1688
+ } catch {
1689
+ failedImages.add(img);
1690
+ }
1691
+ })
1692
+ );
1693
+ return {
1694
+ restore: () => {
1695
+ for (const r3 of restorations) r3();
1696
+ },
1697
+ failedImages
1698
+ };
1630
1699
  }
1631
1700
  function findContextAncestor(el) {
1632
1701
  const minW = 420;
@@ -1649,41 +1718,265 @@ async function renderViewportCrop(cropRect, pixelRatio) {
1649
1718
  const bodyBg = getComputedStyle(document.body).backgroundColor;
1650
1719
  const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
1651
1720
  const backgroundColor = bodyBg && bodyBg !== "rgba(0, 0, 0, 0)" && bodyBg !== "transparent" ? bodyBg : htmlBg && htmlBg !== "rgba(0, 0, 0, 0)" && htmlBg !== "transparent" ? htmlBg : "#ffffff";
1652
- const fullCanvas = await toCanvas(document.documentElement, {
1653
- pixelRatio,
1654
- cacheBust: true,
1655
- 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
- }
1721
+ const { restore: restoreImages, failedImages } = await preResolveImages();
1722
+ try {
1723
+ const fullCanvas = await toCanvas(document.documentElement, {
1724
+ pixelRatio,
1725
+ // cacheBust off — we've already swapped srcs to data URLs.
1726
+ cacheBust: false,
1727
+ backgroundColor,
1728
+ imagePlaceholder: IMAGE_PLACEHOLDER,
1729
+ // Strip only our own overlay layers.
1730
+ filter: (n2) => !(n2 instanceof Element && n2.closest?.("#insitu-root, [data-insitu-layer]"))
1731
+ });
1732
+ const sx = window.scrollX;
1733
+ const sy = window.scrollY;
1734
+ const out = document.createElement("canvas");
1735
+ out.width = Math.max(1, Math.round(cropRect.width * pixelRatio));
1736
+ out.height = Math.max(1, Math.round(cropRect.height * pixelRatio));
1737
+ const ctx = out.getContext("2d");
1738
+ if (!ctx) return { dataUrl: null, failedImages };
1739
+ ctx.drawImage(
1740
+ fullCanvas,
1741
+ Math.round((cropRect.x + sx) * pixelRatio),
1742
+ Math.round((cropRect.y + sy) * pixelRatio),
1743
+ Math.round(cropRect.width * pixelRatio),
1744
+ Math.round(cropRect.height * pixelRatio),
1745
+ 0,
1746
+ 0,
1747
+ out.width,
1748
+ out.height
1749
+ );
1750
+ if (looksBlankUniform(ctx, out.width, out.height)) {
1751
+ return { dataUrl: null, failedImages };
1752
+ }
1753
+ return { dataUrl: out.toDataURL("image/png"), failedImages };
1754
+ } finally {
1755
+ restoreImages();
1756
+ }
1757
+ }
1758
+ function looksBlankUniform(ctx, w3, h3) {
1759
+ if (w3 < 4 || h3 < 4) return false;
1760
+ const samples = [];
1761
+ for (let i3 = 0; i3 < 4; i3++) {
1762
+ for (let j3 = 0; j3 < 4; j3++) {
1763
+ const x2 = Math.floor(w3 * (i3 + 0.5) / 4);
1764
+ const y3 = Math.floor(h3 * (j3 + 0.5) / 4);
1765
+ try {
1766
+ const px2 = ctx.getImageData(x2, y3, 1, 1).data;
1767
+ samples.push(`${px2[0]},${px2[1]},${px2[2]},${px2[3]}`);
1768
+ } catch {
1769
+ return true;
1664
1770
  }
1665
- return true;
1666
1771
  }
1667
- });
1668
- const sx = window.scrollX;
1669
- const sy = window.scrollY;
1670
- const out = document.createElement("canvas");
1671
- out.width = Math.max(1, Math.round(cropRect.width * pixelRatio));
1672
- out.height = Math.max(1, Math.round(cropRect.height * pixelRatio));
1673
- const ctx = out.getContext("2d");
1674
- if (!ctx) return null;
1675
- ctx.drawImage(
1676
- fullCanvas,
1677
- Math.round((cropRect.x + sx) * pixelRatio),
1678
- Math.round((cropRect.y + sy) * pixelRatio),
1679
- Math.round(cropRect.width * pixelRatio),
1680
- Math.round(cropRect.height * pixelRatio),
1681
- 0,
1682
- 0,
1683
- out.width,
1684
- out.height
1772
+ }
1773
+ return new Set(samples).size === 1;
1774
+ }
1775
+ function assessCaptureQuality(cropRect, failedImages) {
1776
+ const out = {
1777
+ unembeddableImages: 0,
1778
+ hasVideo: false,
1779
+ hasCanvas: false
1780
+ };
1781
+ const all = document.querySelectorAll("img, video, canvas");
1782
+ for (const el of all) {
1783
+ if (el instanceof Element && el.closest?.("#insitu-root, [data-insitu-layer]")) {
1784
+ continue;
1785
+ }
1786
+ const r3 = el.getBoundingClientRect();
1787
+ const overlaps = r3.right >= cropRect.x && r3.left <= cropRect.x + cropRect.width && r3.bottom >= cropRect.y && r3.top <= cropRect.y + cropRect.height;
1788
+ if (!overlaps) continue;
1789
+ if (el instanceof HTMLImageElement) {
1790
+ if (failedImages.has(el)) out.unembeddableImages++;
1791
+ } else if (el instanceof HTMLVideoElement) {
1792
+ if (r3.width > 0 && r3.height > 0) out.hasVideo = true;
1793
+ } else if (el instanceof HTMLCanvasElement) {
1794
+ if (r3.width > 0 && r3.height > 0) out.hasCanvas = true;
1795
+ }
1796
+ }
1797
+ return out;
1798
+ }
1799
+ function describeImperfection(q2) {
1800
+ const parts = [];
1801
+ if (q2.unembeddableImages > 0) {
1802
+ parts.push(
1803
+ `${q2.unembeddableImages} non-CORS image${q2.unembeddableImages > 1 ? "s" : ""}`
1804
+ );
1805
+ }
1806
+ if (q2.hasVideo) parts.push("video frame");
1807
+ if (q2.hasCanvas) parts.push("canvas content");
1808
+ return parts.join(" + ");
1809
+ }
1810
+ var displayMediaState = {
1811
+ stream: null,
1812
+ trackEndedHandler: null,
1813
+ idleTimer: null,
1814
+ deniedAt: null
1815
+ };
1816
+ var IDLE_MS = 9e4;
1817
+ var displayMediaListeners = /* @__PURE__ */ new Set();
1818
+ function onDisplayMediaChange(l3) {
1819
+ displayMediaListeners.add(l3);
1820
+ l3(displayMediaState.stream != null);
1821
+ return () => displayMediaListeners.delete(l3);
1822
+ }
1823
+ function notifyDisplayMedia(reason) {
1824
+ const active = displayMediaState.stream != null;
1825
+ for (const l3 of displayMediaListeners) l3(active, reason);
1826
+ }
1827
+ function stopDisplayMedia(reason = "stopped") {
1828
+ if (displayMediaState.stream) {
1829
+ for (const t3 of displayMediaState.stream.getTracks()) t3.stop();
1830
+ }
1831
+ if (displayMediaState.idleTimer) clearTimeout(displayMediaState.idleTimer);
1832
+ if (displayMediaState.trackEndedHandler && displayMediaState.stream) {
1833
+ for (const t3 of displayMediaState.stream.getTracks()) {
1834
+ t3.removeEventListener("ended", displayMediaState.trackEndedHandler);
1835
+ }
1836
+ }
1837
+ displayMediaState.stream = null;
1838
+ displayMediaState.trackEndedHandler = null;
1839
+ displayMediaState.idleTimer = null;
1840
+ notifyDisplayMedia(reason);
1841
+ }
1842
+ function bumpIdleTimer() {
1843
+ if (displayMediaState.idleTimer) clearTimeout(displayMediaState.idleTimer);
1844
+ displayMediaState.idleTimer = setTimeout(
1845
+ () => stopDisplayMedia("idle"),
1846
+ IDLE_MS
1685
1847
  );
1686
- return out.toDataURL("image/png");
1848
+ }
1849
+ function supportsDisplayMedia() {
1850
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function";
1851
+ }
1852
+ async function ensureDisplayMediaStream() {
1853
+ if (!supportsDisplayMedia()) return null;
1854
+ if (displayMediaState.stream) {
1855
+ bumpIdleTimer();
1856
+ return displayMediaState.stream;
1857
+ }
1858
+ if (displayMediaState.deniedAt && Date.now() - displayMediaState.deniedAt < 6e4) {
1859
+ return null;
1860
+ }
1861
+ try {
1862
+ const stream = await navigator.mediaDevices.getDisplayMedia({
1863
+ // `displaySurface: 'browser'` + `preferCurrentTab: true` makes
1864
+ // Chrome/Edge default-select the current tab in the prompt.
1865
+ // Other browsers ignore the hints; user still picks manually.
1866
+ video: {
1867
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1868
+ displaySurface: "browser"
1869
+ },
1870
+ audio: false,
1871
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1872
+ preferCurrentTab: true
1873
+ });
1874
+ displayMediaState.stream = stream;
1875
+ displayMediaState.deniedAt = null;
1876
+ const handler = () => stopDisplayMedia("track-ended");
1877
+ for (const t3 of stream.getTracks()) t3.addEventListener("ended", handler);
1878
+ displayMediaState.trackEndedHandler = handler;
1879
+ bumpIdleTimer();
1880
+ notifyDisplayMedia("granted");
1881
+ return stream;
1882
+ } catch {
1883
+ displayMediaState.deniedAt = Date.now();
1884
+ notifyDisplayMedia("denied");
1885
+ return null;
1886
+ }
1887
+ }
1888
+ async function retryDisplayMedia() {
1889
+ displayMediaState.deniedAt = null;
1890
+ const s3 = await ensureDisplayMediaStream();
1891
+ return s3 != null;
1892
+ }
1893
+ function hideOverlayLayersBriefly() {
1894
+ const id = "insitu-capture-hide";
1895
+ const style = document.createElement("style");
1896
+ style.id = id;
1897
+ style.textContent = `
1898
+ #insitu-root, [data-insitu-layer] { visibility: hidden !important; }
1899
+ `;
1900
+ document.head.appendChild(style);
1901
+ return () => {
1902
+ style.remove();
1903
+ };
1904
+ }
1905
+ async function tryGrabViaDisplayMedia(cropRect, pixelRatio) {
1906
+ const wasActive = displayMediaState.stream != null;
1907
+ const stream = await ensureDisplayMediaStream();
1908
+ if (!stream) return null;
1909
+ const fresh = !wasActive;
1910
+ bumpIdleTimer();
1911
+ const restoreOverlay = hideOverlayLayersBriefly();
1912
+ await new Promise(
1913
+ (r3) => requestAnimationFrame(() => requestAnimationFrame(() => r3()))
1914
+ );
1915
+ try {
1916
+ const track = stream.getVideoTracks()[0];
1917
+ if (!track) return null;
1918
+ let bitmap = null;
1919
+ const Ctor = window.ImageCapture;
1920
+ if (Ctor) {
1921
+ bitmap = await new Ctor(track).grabFrame();
1922
+ } else {
1923
+ bitmap = await grabFrameViaVideo(stream);
1924
+ }
1925
+ if (!bitmap) return null;
1926
+ const frameW = bitmap.width;
1927
+ const frameH = bitmap.height;
1928
+ const scaleX = frameW / window.innerWidth;
1929
+ const scaleY = frameH / window.innerHeight;
1930
+ const sx = Math.max(0, Math.round(cropRect.x * scaleX));
1931
+ const sy = Math.max(0, Math.round(cropRect.y * scaleY));
1932
+ const sw = Math.min(frameW - sx, Math.round(cropRect.width * scaleX));
1933
+ const sh = Math.min(frameH - sy, Math.round(cropRect.height * scaleY));
1934
+ const out = document.createElement("canvas");
1935
+ out.width = Math.max(1, Math.round(cropRect.width * pixelRatio));
1936
+ out.height = Math.max(1, Math.round(cropRect.height * pixelRatio));
1937
+ const ctx = out.getContext("2d");
1938
+ if (!ctx) return null;
1939
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, out.width, out.height);
1940
+ bitmap.close?.();
1941
+ return { dataUrl: out.toDataURL("image/png"), fresh };
1942
+ } finally {
1943
+ restoreOverlay();
1944
+ }
1945
+ }
1946
+ async function grabFrameViaVideo(stream) {
1947
+ const video = document.createElement("video");
1948
+ video.srcObject = stream;
1949
+ video.muted = true;
1950
+ video.playsInline = true;
1951
+ video.style.position = "fixed";
1952
+ video.style.pointerEvents = "none";
1953
+ video.style.opacity = "0";
1954
+ video.style.width = "1px";
1955
+ video.style.height = "1px";
1956
+ document.body.appendChild(video);
1957
+ try {
1958
+ await video.play().catch(() => void 0);
1959
+ await new Promise((resolve, reject) => {
1960
+ const onReady = () => {
1961
+ video.removeEventListener("loadeddata", onReady);
1962
+ resolve();
1963
+ };
1964
+ video.addEventListener("loadeddata", onReady, { once: true });
1965
+ setTimeout(() => reject(new Error("video timeout")), 2e3);
1966
+ });
1967
+ const tmp = document.createElement("canvas");
1968
+ tmp.width = video.videoWidth;
1969
+ tmp.height = video.videoHeight;
1970
+ const ctx = tmp.getContext("2d");
1971
+ if (!ctx) throw new Error("no 2d ctx");
1972
+ ctx.drawImage(video, 0, 0);
1973
+ return await createImageBitmap(tmp);
1974
+ } finally {
1975
+ video.remove();
1976
+ }
1977
+ }
1978
+ if (typeof window !== "undefined") {
1979
+ window.addEventListener("pagehide", () => stopDisplayMedia("pagehide"));
1687
1980
  }
1688
1981
  function elementFor(sel) {
1689
1982
  if (sel.mode === "element") return sel.pointerPath?.[0] ?? null;
@@ -1698,6 +1991,7 @@ async function buildBundle(sel) {
1698
1991
  const el = elementFor(sel);
1699
1992
  const rt = runtimeSnapshot();
1700
1993
  const dpr = window.devicePixelRatio || 1;
1994
+ const settings = getCaptureSettings();
1701
1995
  let screenshot;
1702
1996
  let screenshotUnavailable;
1703
1997
  if (el instanceof HTMLElement) {
@@ -1716,32 +2010,66 @@ async function buildBundle(sel) {
1716
2010
  el.style.outline = "3px solid #ff6b00";
1717
2011
  el.style.outlineOffset = "2px";
1718
2012
  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 {
2013
+ const skipLayer1 = settings.alwaysPixelPerfect;
2014
+ let layer1Result = null;
2015
+ let quality = null;
2016
+ if (!skipLayer1) {
2017
+ const r3 = await renderViewportCrop(cropRect, Math.min(dpr, 1.5));
2018
+ layer1Result = r3.dataUrl;
2019
+ quality = assessCaptureQuality(cropRect, r3.failedImages);
2020
+ }
2021
+ const imperfect = !layer1Result || quality != null && (quality.unembeddableImages > 0 || quality.hasVideo || quality.hasCanvas);
2022
+ if (imperfect || skipLayer1) {
2023
+ const grab = await tryGrabViaDisplayMedia(
2024
+ cropRect,
2025
+ Math.min(dpr, 2)
2026
+ );
2027
+ if (grab) {
2028
+ screenshot = {
2029
+ mime: "image/png",
2030
+ dataUrl: grab.dataUrl,
2031
+ bounds: {
2032
+ x: cropRect.x,
2033
+ y: cropRect.y,
2034
+ width: cropRect.width,
2035
+ height: cropRect.height
2036
+ },
2037
+ source: "display-media"
2038
+ };
2039
+ } else if (layer1Result) {
2040
+ const reason = quality ? describeImperfection(quality) : "non-CORS content";
2041
+ screenshot = {
2042
+ mime: "image/png",
2043
+ dataUrl: layer1Result,
2044
+ bounds: {
2045
+ x: cropRect.x,
2046
+ y: cropRect.y,
2047
+ width: cropRect.width,
2048
+ height: cropRect.height
2049
+ },
2050
+ source: "rasterise",
2051
+ qualityNote: `${reason} couldn't be embedded \u2014 grant tab capture for pixel-perfect screenshots`
2052
+ };
2053
+ } else {
2054
+ screenshotUnavailable = supportsDisplayMedia() ? "rasterise failed \u2014 grant tab capture for pixel-perfect screenshots" : "rasterise failed and tab capture unsupported in this browser";
2055
+ }
2056
+ } else if (layer1Result) {
1730
2057
  screenshot = {
1731
2058
  mime: "image/png",
1732
- dataUrl,
1733
- // Bounds describe the SCREENSHOT (the crop region) so
1734
- // the dashboard knows what slice of viewport this is.
2059
+ dataUrl: layer1Result,
1735
2060
  bounds: {
1736
2061
  x: cropRect.x,
1737
2062
  y: cropRect.y,
1738
2063
  width: cropRect.width,
1739
2064
  height: cropRect.height
1740
- }
2065
+ },
2066
+ source: "rasterise"
1741
2067
  };
2068
+ } else {
2069
+ screenshotUnavailable = "rasterise produced an empty image";
1742
2070
  }
1743
- } catch {
1744
- screenshotUnavailable = "rasterise failed";
2071
+ } catch (err) {
2072
+ screenshotUnavailable = err instanceof Error ? `rasterise failed: ${err.message}` : "rasterise failed";
1745
2073
  } finally {
1746
2074
  el.style.outline = orig.outline;
1747
2075
  el.style.outlineOffset = orig.outlineOffset;
@@ -1784,5 +2112,11 @@ export {
1784
2112
  installRuntimeCollectors,
1785
2113
  runtimeErrorCount,
1786
2114
  beginPick,
2115
+ getCaptureSettings,
2116
+ setCaptureSettings,
2117
+ onCaptureSettingsChange,
2118
+ onDisplayMediaChange,
2119
+ stopDisplayMedia,
2120
+ retryDisplayMedia,
1787
2121
  buildBundle
1788
2122
  };
@@ -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-65BGOX2M.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,
@@ -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-65BGOX2M.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,
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  mountCaptureOnly
3
- } from "./chunk-YUNYW2IC.js";
3
+ } from "./chunk-TTH4NBFA.js";
4
4
  import {
5
5
  mountInSitue
6
- } from "./chunk-VMBBJKFF.js";
7
- import "./chunk-X7V2UEBO.js";
6
+ } from "./chunk-7GRJ5SZQ.js";
7
+ import "./chunk-65BGOX2M.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-7GRJ5SZQ.js";
4
+ import "./chunk-65BGOX2M.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.4",
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",