@insitue/sdk 0.1.1 → 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-BYR4ZXVS.js";
4
- import "./chunk-6SMY7D6U.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-6SMY7D6U.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)) {
@@ -1592,10 +1639,6 @@ async function toCanvas(node, options = {}) {
1592
1639
  context.drawImage(img, 0, 0, canvas.width, canvas.height);
1593
1640
  return canvas;
1594
1641
  }
1595
- async function toPng(node, options = {}) {
1596
- const canvas = await toCanvas(node, options);
1597
- return canvas.toDataURL();
1598
- }
1599
1642
 
1600
1643
  // src/capture.ts
1601
1644
  function crossOrigin(url) {
@@ -1608,29 +1651,290 @@ function crossOrigin(url) {
1608
1651
  return false;
1609
1652
  }
1610
1653
  }
1611
- function crossOriginMediaReason(root) {
1612
- const els = [root, ...root.querySelectorAll("*")];
1613
- for (const el of els) {
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
+ );
1657
+ function findContextAncestor(el) {
1658
+ const minW = 420;
1659
+ const minH = 140;
1660
+ const maxW = window.innerWidth * 1.2;
1661
+ const maxH = window.innerHeight * 1.2;
1662
+ let cur = el;
1663
+ for (let depth = 0; depth < 8; depth++) {
1664
+ const r3 = cur.getBoundingClientRect();
1665
+ if (r3.width >= minW && r3.height >= minH) return cur;
1666
+ const parent = cur.parentElement;
1667
+ if (!parent) return cur;
1668
+ const pr = parent.getBoundingClientRect();
1669
+ if (pr.width > maxW || pr.height > maxH) return cur;
1670
+ cur = parent;
1671
+ }
1672
+ return cur;
1673
+ }
1674
+ async function renderViewportCrop(cropRect, pixelRatio) {
1675
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
1676
+ const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
1677
+ const backgroundColor = bodyBg && bodyBg !== "rgba(0, 0, 0, 0)" && bodyBg !== "transparent" ? bodyBg : htmlBg && htmlBg !== "rgba(0, 0, 0, 0)" && htmlBg !== "transparent" ? htmlBg : "#ffffff";
1678
+ const fullCanvas = await toCanvas(document.documentElement, {
1679
+ pixelRatio,
1680
+ cacheBust: true,
1681
+ backgroundColor,
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]"))
1686
+ });
1687
+ const sx = window.scrollX;
1688
+ const sy = window.scrollY;
1689
+ const out = document.createElement("canvas");
1690
+ out.width = Math.max(1, Math.round(cropRect.width * pixelRatio));
1691
+ out.height = Math.max(1, Math.round(cropRect.height * pixelRatio));
1692
+ const ctx = out.getContext("2d");
1693
+ if (!ctx) return null;
1694
+ ctx.drawImage(
1695
+ fullCanvas,
1696
+ Math.round((cropRect.x + sx) * pixelRatio),
1697
+ Math.round((cropRect.y + sy) * pixelRatio),
1698
+ Math.round(cropRect.width * pixelRatio),
1699
+ Math.round(cropRect.height * pixelRatio),
1700
+ 0,
1701
+ 0,
1702
+ out.width,
1703
+ out.height
1704
+ );
1705
+ if (looksBlankUniform(ctx, out.width, out.height)) {
1706
+ return null;
1707
+ }
1708
+ return out.toDataURL("image/png");
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;
1614
1741
  if (el instanceof HTMLImageElement) {
1615
- if (crossOrigin(el.currentSrc || el.src) && el.crossOrigin == null) {
1616
- const host = (() => {
1617
- try {
1618
- return new URL(el.currentSrc || el.src).host;
1619
- } catch {
1620
- return "cross-origin";
1621
- }
1622
- })();
1623
- return `cross-origin <img> (${host})`;
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++;
1624
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;
1625
1753
  }
1626
- const bg = getComputedStyle(el).backgroundImage;
1627
- const m3 = bg && bg !== "none" ? /url\(["']?([^"')]+)["']?\)/.exec(bg) : null;
1628
- if (m3 && crossOrigin(m3[1])) return "cross-origin CSS background image";
1629
- if ((el instanceof HTMLVideoElement || el instanceof HTMLCanvasElement) && crossOrigin(el.src)) {
1630
- return `cross-origin <${el.tagName.toLowerCase()}>`;
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);
1631
1793
  }
1632
1794
  }
1633
- return null;
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"));
1634
1938
  }
1635
1939
  function elementFor(sel) {
1636
1940
  if (sel.mode === "element") return sel.pointerPath?.[0] ?? null;
@@ -1645,33 +1949,90 @@ async function buildBundle(sel) {
1645
1949
  const el = elementFor(sel);
1646
1950
  const rt = runtimeSnapshot();
1647
1951
  const dpr = window.devicePixelRatio || 1;
1952
+ const settings = getCaptureSettings();
1648
1953
  let screenshot;
1649
1954
  let screenshotUnavailable;
1650
1955
  if (el instanceof HTMLElement) {
1651
- const taint = crossOriginMediaReason(el);
1652
- if (taint) {
1653
- screenshotUnavailable = `${taint} \u2014 can't rasterise in-browser`;
1654
- } else {
1655
- try {
1656
- const r3 = el.getBoundingClientRect();
1657
- const dataUrl = await toPng(el, {
1658
- pixelRatio: Math.min(dpr, 2),
1659
- cacheBust: true,
1660
- // Don't try to screenshot our own overlay if it overlaps.
1661
- filter: (n2) => !(n2 instanceof Element && n2.closest?.("#insitu-root, [data-insitu-layer]"))
1662
- });
1663
- if (!dataUrl || dataUrl.length < 256) {
1664
- screenshotUnavailable = "rasterise produced an empty image";
1665
- } else {
1956
+ const context = findContextAncestor(el);
1957
+ const cr = context.getBoundingClientRect();
1958
+ const cropRect = new DOMRect(
1959
+ Math.max(0, cr.x),
1960
+ Math.max(0, cr.y),
1961
+ Math.min(window.innerWidth, cr.right) - Math.max(0, cr.x),
1962
+ Math.min(window.innerHeight, cr.bottom) - Math.max(0, cr.y)
1963
+ );
1964
+ const orig = {
1965
+ outline: el.style.outline,
1966
+ outlineOffset: el.style.outlineOffset
1967
+ };
1968
+ el.style.outline = "3px solid #ff6b00";
1969
+ el.style.outlineOffset = "2px";
1970
+ try {
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";
1666
2001
  screenshot = {
1667
2002
  mime: "image/png",
1668
- dataUrl,
1669
- bounds: { x: r3.x, y: r3.y, width: r3.width, height: r3.height }
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`
1670
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";
1671
2015
  }
1672
- } catch {
1673
- screenshotUnavailable = "rasterise failed";
2016
+ } else if (layer1Result) {
2017
+ screenshot = {
2018
+ mime: "image/png",
2019
+ dataUrl: layer1Result,
2020
+ bounds: {
2021
+ x: cropRect.x,
2022
+ y: cropRect.y,
2023
+ width: cropRect.width,
2024
+ height: cropRect.height
2025
+ },
2026
+ source: "rasterise"
2027
+ };
2028
+ } else {
2029
+ screenshotUnavailable = "rasterise produced an empty image";
1674
2030
  }
2031
+ } catch (err) {
2032
+ screenshotUnavailable = err instanceof Error ? `rasterise failed: ${err.message}` : "rasterise failed";
2033
+ } finally {
2034
+ el.style.outline = orig.outline;
2035
+ el.style.outlineOffset = orig.outlineOffset;
1675
2036
  }
1676
2037
  }
1677
2038
  return {
@@ -1711,5 +2072,11 @@ export {
1711
2072
  installRuntimeCollectors,
1712
2073
  runtimeErrorCount,
1713
2074
  beginPick,
2075
+ getCaptureSettings,
2076
+ setCaptureSettings,
2077
+ onCaptureSettingsChange,
2078
+ onDisplayMediaChange,
2079
+ stopDisplayMedia,
2080
+ retryDisplayMedia,
1714
2081
  buildBundle
1715
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-6SMY7D6U.js";
18
+ } from "./chunk-RYS5Z2BU.js";
13
19
 
14
20
  // src/client.ts
15
21
  var CompanionClient = class {
@@ -173,6 +179,42 @@ function diffLines(diff) {
173
179
  return k("div", { style: `color:${color};white-space:pre` }, ln || " ");
174
180
  });
175
181
  }
182
+ function renderMessageBody(text) {
183
+ const parts = [];
184
+ const lines = text.split("\n");
185
+ let buf = [];
186
+ let inCode = false;
187
+ let lang = "";
188
+ const flush = () => {
189
+ if (buf.length === 0) return;
190
+ parts.push({ code: inCode, lang, text: buf.join("\n") });
191
+ buf = [];
192
+ };
193
+ for (const line of lines) {
194
+ const fence = /^```(\w*)\s*$/.exec(line);
195
+ if (fence) {
196
+ flush();
197
+ inCode = !inCode;
198
+ lang = inCode ? fence[1] ?? "" : "";
199
+ continue;
200
+ }
201
+ buf.push(line);
202
+ }
203
+ flush();
204
+ return parts.map(
205
+ (p) => p.code ? k(
206
+ "div",
207
+ {
208
+ style: `${card};padding:8px;margin:4px 0;font:${mono};color:#ececef;overflow-x:auto;white-space:pre`
209
+ },
210
+ p.text
211
+ ) : k(
212
+ "div",
213
+ { style: "white-space:pre-wrap;word-break:break-word" },
214
+ p.text
215
+ )
216
+ );
217
+ }
176
218
  function diffBlock(changes) {
177
219
  return changes.map(
178
220
  (c) => k("div", { style: "margin:6px 0" }, [
@@ -202,6 +244,11 @@ function App(props) {
202
244
  const [showCtx, setShowCtx] = d(false);
203
245
  const [showSettings, setShowSettings] = d(false);
204
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);
205
252
  const [agentReady, setAgentReady] = d(null);
206
253
  const [agentNote, setAgentNote] = d("");
207
254
  const [chatInput, setChatInput] = d("");
@@ -225,6 +272,7 @@ function App(props) {
225
272
  const changesRef = A([]);
226
273
  const activeTurnRef = A(null);
227
274
  const threadRef = A(null);
275
+ const panelRef = A(null);
228
276
  autoApplyRef.current = autoApply;
229
277
  changesRef.current = changes;
230
278
  activeTurnRef.current = activeTurn;
@@ -235,6 +283,32 @@ function App(props) {
235
283
  }
236
284
  return [...ms, { role: "agent", text: delta }];
237
285
  });
286
+ const storageKey = `insitue:session:${typeof window !== "undefined" ? window.location.origin : "default"}`;
287
+ const hydratedRef = A(false);
288
+ y(() => {
289
+ if (hydratedRef.current) return;
290
+ hydratedRef.current = true;
291
+ try {
292
+ const raw = window.localStorage.getItem(storageKey);
293
+ if (!raw) return;
294
+ const saved = JSON.parse(raw);
295
+ if (Array.isArray(saved.messages)) setMessages(saved.messages);
296
+ if (Array.isArray(saved.history)) setHistory(saved.history);
297
+ if (typeof saved.autoApply === "boolean") setAutoApply(saved.autoApply);
298
+ if (typeof saved.open === "boolean") setOpen(saved.open);
299
+ } catch {
300
+ }
301
+ }, [storageKey]);
302
+ y(() => {
303
+ if (!hydratedRef.current) return;
304
+ try {
305
+ window.localStorage.setItem(
306
+ storageKey,
307
+ JSON.stringify({ messages, history, autoApply, open })
308
+ );
309
+ } catch {
310
+ }
311
+ }, [messages, history, autoApply, open, storageKey]);
238
312
  y(() => {
239
313
  installRuntimeCollectors();
240
314
  const c = new CompanionClient(props.port, {
@@ -370,6 +444,37 @@ function App(props) {
370
444
  const el = threadRef.current;
371
445
  if (el) el.scrollTop = el.scrollHeight;
372
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
+ }, []);
459
+ y(() => {
460
+ const onKey = (ev) => {
461
+ const meta = ev.metaKey || ev.ctrlKey;
462
+ if (meta && ev.key === "k") {
463
+ ev.preventDefault();
464
+ setOpen(true);
465
+ setTimeout(() => {
466
+ const ta = panelRef.current?.querySelector(
467
+ "textarea"
468
+ );
469
+ ta?.focus();
470
+ }, 0);
471
+ } else if (ev.key === "Escape" && open && !chatInput.trim()) {
472
+ setOpen(false);
473
+ }
474
+ };
475
+ window.addEventListener("keydown", onKey);
476
+ return () => window.removeEventListener("keydown", onKey);
477
+ }, [open, chatInput]);
373
478
  const captureSel = async (sel) => {
374
479
  setLastSel(sel);
375
480
  const b = await buildBundle(sel);
@@ -394,9 +499,30 @@ function App(props) {
394
499
  }
395
500
  };
396
501
  const sendChat = () => {
397
- if (!client || !bundle || !chatInput.trim() || turnBusy) return;
398
- const turnId = bundle.id;
502
+ if (!client || !chatInput.trim() || turnBusy) return;
399
503
  const text = chatInput.trim();
504
+ if (text.startsWith("/")) {
505
+ const [cmd, ...rest] = text.split(/\s+/);
506
+ if (cmd === "/clear") {
507
+ setMessages([]);
508
+ setChatInput("");
509
+ return;
510
+ }
511
+ if (cmd === "/undo") {
512
+ const top = history.find((h) => h.status === "applied");
513
+ if (top) client.sendUndo(top.turnId);
514
+ setChatInput("");
515
+ return;
516
+ }
517
+ if (cmd === "/commit") {
518
+ const msg = rest.join(" ") || "Apply InSitue session changes";
519
+ client.sendCommitSession(msg);
520
+ setChatInput("");
521
+ return;
522
+ }
523
+ }
524
+ if (!bundle) return;
525
+ const turnId = bundle.id;
400
526
  setActiveTurn({ turnId, prompt: text, sel: lastSel });
401
527
  setMessages((ms) => [...ms, { role: "user", text }]);
402
528
  setChatInput("");
@@ -550,12 +676,26 @@ ${resolved.snippet}`
550
676
  },
551
677
  [
552
678
  ...messages.map(
553
- (m) => k(
679
+ (m, i) => k(
554
680
  "div",
555
681
  {
556
- style: m.role === "user" ? `align-self:flex-end;max-width:88%;${card};border-color:#2e2e3c;padding:6px 8px;color:#ececef;white-space:pre-wrap;word-break:break-word` : `align-self:flex-start;max-width:96%;padding:6px 8px;color:#ececef;white-space:pre-wrap;word-break:break-word`
682
+ style: m.role === "user" ? `align-self:flex-end;max-width:88%;${card};border-color:#2e2e3c;padding:6px 8px;color:#ececef;white-space:pre-wrap;word-break:break-word;cursor:pointer` : `align-self:flex-start;max-width:96%;padding:6px 8px;color:#ececef`,
683
+ // Click any prior user message to re-populate the
684
+ // input — matches Claude.ai / Cursor edit-and-retry.
685
+ // The original turn stays in the thread; sending
686
+ // creates a new turn.
687
+ onClick: m.role === "user" ? () => {
688
+ setChatInput(m.text);
689
+ setTimeout(() => {
690
+ const ta = panelRef.current?.querySelector(
691
+ "textarea"
692
+ );
693
+ ta?.focus();
694
+ }, 0);
695
+ } : void 0,
696
+ title: m.role === "user" ? "click to edit + retry" : void 0
557
697
  },
558
- m.text
698
+ m.role === "agent" ? renderMessageBody(m.text) : m.text
559
699
  )
560
700
  ),
561
701
  thinking && turnBusy ? k(
@@ -687,7 +827,7 @@ ${resolved.snippet}`
687
827
  proposed,
688
828
  k("textarea", {
689
829
  value: chatInput,
690
- placeholder: messages.length ? "reply\u2026 (the agent remembers this thread)" : "what does this do? \xB7 make the padding bigger \xB7 fix this bug",
830
+ placeholder: messages.length ? "reply\u2026 (the agent remembers this thread) \xB7 /undo /clear /commit" : "what does this do? \xB7 make the padding bigger \xB7 fix this bug \xB7 \u2318K to focus",
691
831
  rows: 2,
692
832
  onInput: (ev) => setChatInput(ev.target.value),
693
833
  onKeyDown: (ev) => {
@@ -847,11 +987,83 @@ ${resolved.snippet}`
847
987
  "div",
848
988
  { style: `color:${muted};margin-top:4px` },
849
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."
850
1011
  )
851
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;
852
1063
  const panel = open ? k(
853
1064
  "div",
854
1065
  {
1066
+ ref: panelRef,
855
1067
  style: {
856
1068
  position: "fixed",
857
1069
  bottom: "64px",
@@ -903,6 +1115,8 @@ ${resolved.snippet}`
903
1115
  ]
904
1116
  ),
905
1117
  settings,
1118
+ captureActivePill,
1119
+ captureDeniedNudge,
906
1120
  ctx,
907
1121
  conversation,
908
1122
  timeline,
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  mountCaptureOnly
3
- } from "./chunk-BYR4ZXVS.js";
3
+ } from "./chunk-PRCHVT5A.js";
4
4
  import {
5
5
  mountInSitue
6
- } from "./chunk-LGN4LKXD.js";
7
- import "./chunk-6SMY7D6U.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-LGN4LKXD.js";
4
- import "./chunk-6SMY7D6U.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.1",
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",