@sightspool/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,10 @@ them).
14
14
  > **not** act on your surface (surveys/nudges/experiments are Wave 0005, and every one
15
15
  > is human-gated). Trigger sensitivity and intent inference calibrate with live traffic.
16
16
 
17
+ **Docs:** [full reference + CSP & framework guides](https://sdk.sightspool.com/) ·
18
+ [llms.txt](https://sdk.sightspool.com/llms.txt) (the machine-readable install/CSP/config
19
+ doc for agents) · [npm](https://www.npmjs.com/package/@sightspool/sdk)
20
+
17
21
  ---
18
22
 
19
23
  ## Install — two lines
@@ -83,9 +87,10 @@ The script tag also reads these optional attributes (the no-build equivalent of
83
87
 
84
88
  | option | type | default | purpose |
85
89
  |---|---|---|---|
86
- | `key` | `string` | — | **required.** Your publishable key (`pk_live_…`), from the Connections → In-product SDK card. Publishable — safe to ship in client JS. |
90
+ | `key` | `string` | — | **required.** Your publishable key (`pk_test_…` / `pk_live_…`), from the Connections → In-product SDK card. Publishable — safe to ship in client JS. See [Keys & environments](#keys--environments). |
87
91
  | `endpoint` | `string` | the bundle's origin (script tag) / `https://app.sightspool.com` (npm) | Ingest base URL. The `<script>` install auto-resolves it to wherever `sdk.global.js` was served from (your app), so the key alone is enough; override for a CDN-hosted bundle or dev. |
88
92
  | `boundaryAsk` | `boolean` | `true` | Show the one-tap "did you do what you came to do?" ask at session boundaries. |
93
+ | `interventions` | `boolean` | `true` | Show human-approved **surveys** served by your Sightspool workspace (see [Interventions](#interventions-surveys)). Set `false` to capture only. |
89
94
  | `consent` | `boolean` | `true` | Start capturing immediately. Set `false` to stay paused until you call `Sightspool.consent(true)` (or `start()`) after obtaining consent. |
90
95
  | `redact` | `string[]` | `[]` | CSS selectors whose captured text is **masked** (replaced with `‹redacted›`) before anything leaves the page. The event is still recorded — only its label is masked. |
91
96
  | `block` | `string[]` | `[]` | CSS selectors whose events are **dropped entirely** (the hard opt-out). Equivalent to putting `data-sightspool-ignore` on the element. |
@@ -107,6 +112,193 @@ un-allowlisted origin.
107
112
 
108
113
  ---
109
114
 
115
+ ## Keys & environments
116
+
117
+ Your key is **publishable** — safe to ship in client JS (the Stripe `pk_` model). Two
118
+ prefixes, one per environment:
119
+
120
+ | prefix | use it for |
121
+ |---|---|
122
+ | `pk_test_…` | development / staging / preview deploys |
123
+ | `pk_live_…` | production |
124
+
125
+ The SDK treats both prefixes **identically** — there's no client-side special-casing; the
126
+ prefix tells *Sightspool* (at ingest) which environment a Signal belongs to, so test traffic
127
+ never mixes into production analytics. Issue both from **Connections → In-product SDK**.
128
+
129
+ > The SDK also **no-ops on localhost** by default (see `captureOnLocalhost`), so even a
130
+ > `pk_live_` key won't capture from `npm run dev`. Test keys are for *deployed* non-prod
131
+ > environments (staging, previews).
132
+
133
+ Keep the key in an environment variable rather than hardcoding it, and pick test vs live by
134
+ environment. The key is exposed to the browser, so use your framework's **client** env-var
135
+ prefix (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, …) — it's publishable, so that's expected:
136
+
137
+ ```js
138
+ Sightspool.init({ key: process.env.NEXT_PUBLIC_SIGHTSPOOL_KEY })
139
+ ```
140
+
141
+ ```bash
142
+ # .env.development / preview
143
+ NEXT_PUBLIC_SIGHTSPOOL_KEY=pk_test_…
144
+ # .env.production
145
+ NEXT_PUBLIC_SIGHTSPOOL_KEY=pk_live_…
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Framework integration
151
+
152
+ ### Next.js (App Router) — `next/script`
153
+
154
+ The idiomatic install is `next/script`, not a raw `<script>`. Add it once in your root
155
+ layout — the tag auto-`init`s from `data-sightspool-key`:
156
+
157
+ ```tsx
158
+ // app/layout.tsx
159
+ import Script from 'next/script'
160
+
161
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
162
+ return (
163
+ <html lang="en">
164
+ <body>
165
+ {children}
166
+ <Script
167
+ src="https://app.sightspool.com/sdk.global.js"
168
+ data-sightspool-key={process.env.NEXT_PUBLIC_SIGHTSPOOL_KEY}
169
+ strategy="afterInteractive"
170
+ />
171
+ </body>
172
+ </html>
173
+ )
174
+ }
175
+ ```
176
+
177
+ Then `identify` the user once known (e.g. a client component after auth):
178
+
179
+ ```tsx
180
+ 'use client'
181
+ useEffect(() => {
182
+ window.Sightspool?.identify(user.id, { account: user.account, plan: user.plan })
183
+ }, [user])
184
+ ```
185
+
186
+ > `data-sightspool-key` is inlined at build time, so `NEXT_PUBLIC_SIGHTSPOOL_KEY` must be set
187
+ > in the environment Next builds in. `strategy="afterInteractive"` keeps it off the critical
188
+ > path.
189
+
190
+ ### React / Next.js — `@sightspool/react`
191
+
192
+ For React apps, use the official bindings — a declarative provider over the core SDK:
193
+
194
+ ```bash
195
+ npm install @sightspool/react @sightspool/sdk react
196
+ ```
197
+
198
+ ```tsx
199
+ import { SightspoolProvider } from '@sightspool/react'
200
+
201
+ <SightspoolProvider
202
+ apiKey={process.env.NEXT_PUBLIC_SIGHTSPOOL_KEY!}
203
+ identity={user && { userId: user.id, account: user.account, plan: user.plan }}
204
+ >
205
+ <App />
206
+ </SightspoolProvider>
207
+ ```
208
+
209
+ It ships a `"use client"` banner (drops straight into a Next.js App Router **server**
210
+ layout), re-fires `identify` whenever `identity` changes, and takes a reactive `consent`
211
+ prop for cookie banners. Full API:
212
+ [@sightspool/react](https://www.npmjs.com/package/@sightspool/react) ·
213
+ [packages/react](https://github.com/sightspool/sdk/tree/main/packages/react).
214
+
215
+ <details><summary>Or wire the core SDK by hand (no wrapper)</summary>
216
+
217
+ ```tsx
218
+ import { useEffect } from 'react'
219
+ import Sightspool from '@sightspool/sdk'
220
+
221
+ export function SightspoolBoot({ userId, account, plan }) {
222
+ useEffect(() => { Sightspool.init({ key: import.meta.env.VITE_SIGHTSPOOL_KEY }) }, [])
223
+ useEffect(() => { if (userId) Sightspool.identify(userId, { account, plan }) }, [userId, account, plan])
224
+ return null
225
+ }
226
+ ```
227
+ </details>
228
+
229
+ ---
230
+
231
+ ## Content-Security-Policy
232
+
233
+ If your app sets a CSP, allow the SDK's two footprints — the **script load** and the
234
+ **ingest beacon**. With the standard install they're the *same host* (the bundle is served
235
+ from the app it ingests to), so it's one host in two directives:
236
+
237
+ **Script-tag install**
238
+
239
+ ```
240
+ script-src https://app.sightspool.com;
241
+ connect-src https://app.sightspool.com;
242
+ ```
243
+
244
+ **npm / bundler install** — the SDK is bundled into your own first-party JS, so no
245
+ `script-src` host is needed; only the ingest origin:
246
+
247
+ ```
248
+ connect-src https://app.sightspool.com;
249
+ ```
250
+
251
+ If you pass a custom `endpoint`, use *that* origin in `connect-src`. The beacon goes via
252
+ `navigator.sendBeacon` with a `fetch(keepalive)` fallback — both governed by `connect-src`.
253
+
254
+ - **`strict-dynamic` / nonce.** Under `script-src 'strict-dynamic'`, host allowlists are
255
+ ignored for scripts — give the `<script>` tag your per-request nonce (`nonce={nonce}` in
256
+ Next) so it's trusted. `connect-src` still needs the ingest host.
257
+ - **Prompt styles.** The one-tap prompt renders into a **shadow root** and injects its own
258
+ `<style>`. Under a strict `style-src` without `'unsafe-inline'`, those styles may not apply
259
+ — the prompt stays **fully functional but unstyled** (the SDK never throws into your page).
260
+ Add `'unsafe-inline'` to `style-src` if you want it styled.
261
+
262
+ ---
263
+
264
+ ## Interventions (surveys)
265
+
266
+ The SDK is two-way. Besides *capturing*, it can show a **human-approved survey** at the
267
+ moment of friction — the "ask" side of Sightspool. You don't author these in code: your
268
+ team proposes a survey off a proven finding in the app, **a human approves it**, sets who
269
+ sees it (route / account / plan), and the SDK serves it. Nothing reaches a user without
270
+ that approval ("no proof **and** no approval, no act").
271
+
272
+ It's on by default — the same one or two install lines that capture also serve. When the
273
+ SDK loads (and on each route change) it asks your workspace *"anything to show this user
274
+ here?"*; if there's a matching approved survey, it renders a small card in a shadow root
275
+ (your CSS can't reach it; it leaks no styles), and posts the answer back.
276
+
277
+ ```js
278
+ Sightspool.init({ key: "pk_live_…" }); // serving is on by default
279
+ Sightspool.init({ key: "pk_live_…", interventions: false }); // capture only
280
+ ```
281
+
282
+ ```html
283
+ <!-- script-tag install: opt out with one attribute -->
284
+ <script src="https://app.sightspool.com/sdk.global.js"
285
+ data-sightspool-key="pk_live_…"
286
+ data-sightspool-no-interventions></script>
287
+ ```
288
+
289
+ **What shows, and how often** — all server-gated, so you stay in control:
290
+
291
+ - **Targeting** — only to the route / account / plan the approver chose.
292
+ - **De-dup** — a survey a user has answered never reappears (a per-user key is kept
293
+ locally; it's anonymous unless you've called `identify()`).
294
+ - **Won't pester** — at most one survey on screen, one per session, and it shares a
295
+ cooldown with the boundary ask so the two never stack back-to-back. The run also stops
296
+ itself once it hits the approver's response target.
297
+
298
+ The survey widget is **lazily loaded** — it splits into its own chunk and adds nothing to
299
+ your bundle until a survey is actually served. Today the SDK renders one-tap / short-text
300
+ surveys; richer types (incl. voice micro-research) render as they ship.
301
+
110
302
  ## What it captures
111
303
 
112
304
  - **Passively, no wiring** — route/screen sequence, clicks, **dead-clicks** and
package/dist/index.cjs CHANGED
@@ -199,6 +199,157 @@ input { font: inherit; font-size: 13px; width: 100%; box-sizing: border-box; pad
199
199
  }
200
200
  });
201
201
 
202
+ // src/survey.ts
203
+ var survey_exports = {};
204
+ __export(survey_exports, {
205
+ showSurvey: () => showSurvey
206
+ });
207
+ function showSurvey(config) {
208
+ return new Promise((resolve) => {
209
+ if (typeof document === "undefined") {
210
+ resolve({ dismissed: true });
211
+ return;
212
+ }
213
+ let settled = false;
214
+ const host = document.createElement("div");
215
+ const shadow = host.attachShadow({ mode: "open" });
216
+ function teardown() {
217
+ try {
218
+ host.remove();
219
+ } catch (e) {
220
+ }
221
+ }
222
+ function finish(result, thank) {
223
+ if (settled) return;
224
+ settled = true;
225
+ resolve(result);
226
+ if (thank) {
227
+ thankYou();
228
+ setTimeout(teardown, THANKS_MS);
229
+ } else {
230
+ teardown();
231
+ }
232
+ }
233
+ const style = document.createElement("style");
234
+ style.textContent = STYLE2;
235
+ shadow.appendChild(style);
236
+ const wrap = document.createElement("div");
237
+ wrap.className = "wrap";
238
+ shadow.appendChild(wrap);
239
+ function card() {
240
+ wrap.innerHTML = "";
241
+ const c = document.createElement("div");
242
+ c.className = "card";
243
+ wrap.appendChild(c);
244
+ return c;
245
+ }
246
+ function withClose(c) {
247
+ const x = document.createElement("button");
248
+ x.className = "x";
249
+ x.textContent = "\xD7";
250
+ x.setAttribute("aria-label", "Dismiss");
251
+ x.onclick = () => finish({ dismissed: true }, false);
252
+ c.appendChild(x);
253
+ }
254
+ function question() {
255
+ var _a2, _b, _c;
256
+ const c = card();
257
+ withClose(c);
258
+ const q = document.createElement("p");
259
+ q.className = "q";
260
+ q.textContent = config.question;
261
+ c.appendChild(q);
262
+ for (const opt of ((_a2 = config.options) != null ? _a2 : []).slice(0, 6)) {
263
+ const b = document.createElement("button");
264
+ b.className = "opt";
265
+ b.textContent = opt;
266
+ b.onclick = () => finish({ choice: opt }, true);
267
+ c.appendChild(b);
268
+ }
269
+ if (config.allow_text) {
270
+ const se = document.createElement("button");
271
+ se.className = "opt";
272
+ se.textContent = ((_c = (_b = config.options) == null ? void 0 : _b.length) != null ? _c : 0) > 0 ? "Something else\u2026" : "Answer\u2026";
273
+ se.onclick = freeText;
274
+ c.appendChild(se);
275
+ }
276
+ }
277
+ function freeText() {
278
+ const c = card();
279
+ withClose(c);
280
+ const q = document.createElement("p");
281
+ q.className = "q";
282
+ q.textContent = config.question;
283
+ c.appendChild(q);
284
+ const input = document.createElement("input");
285
+ input.type = "text";
286
+ input.placeholder = "In your own words\u2026";
287
+ c.appendChild(input);
288
+ const row = document.createElement("div");
289
+ row.className = "row";
290
+ const send = document.createElement("button");
291
+ send.className = "primary";
292
+ send.textContent = "Send";
293
+ const submit = () => {
294
+ const text = input.value.trim();
295
+ if (!text) {
296
+ finish({ dismissed: true }, false);
297
+ return;
298
+ }
299
+ finish({ text }, true);
300
+ };
301
+ send.onclick = submit;
302
+ input.onkeydown = (e) => {
303
+ if (e.key === "Enter") submit();
304
+ };
305
+ row.appendChild(send);
306
+ c.appendChild(row);
307
+ try {
308
+ input.focus();
309
+ } catch (e) {
310
+ }
311
+ }
312
+ function thankYou() {
313
+ const c = card();
314
+ const p = document.createElement("p");
315
+ p.className = "done";
316
+ p.textContent = "Thanks \u2014 that helps.";
317
+ c.appendChild(p);
318
+ }
319
+ try {
320
+ document.body.appendChild(host);
321
+ question();
322
+ } catch (e) {
323
+ finish({ dismissed: true }, false);
324
+ }
325
+ });
326
+ }
327
+ var STYLE2, THANKS_MS;
328
+ var init_survey = __esm({
329
+ "src/survey.ts"() {
330
+ STYLE2 = `
331
+ :host { all: initial; }
332
+ .wrap { position: fixed; bottom: 20px; right: 20px; z-index: 2147483000;
333
+ width: 340px; max-width: calc(100vw - 32px); font-family: system-ui, -apple-system, sans-serif; }
334
+ .card { position: relative; background: #fff; color: #18181b; border: 1px solid #e4e4e7;
335
+ border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.12); padding: 16px; }
336
+ .q { font-size: 14px; font-weight: 600; margin: 0 0 12px; line-height: 1.35; padding-right: 16px; }
337
+ .row { display: flex; gap: 8px; flex-wrap: wrap; }
338
+ button { font: inherit; font-size: 13px; cursor: pointer; border-radius: 8px; padding: 8px 12px;
339
+ border: 1px solid #e4e4e7; background: #fafafa; color: #18181b; }
340
+ button:hover { background: #f4f4f5; }
341
+ button.primary { background: #18181b; color: #fff; border-color: #18181b; }
342
+ .opt { display: block; width: 100%; text-align: left; margin-bottom: 6px; }
343
+ .x { position: absolute; top: 8px; right: 10px; border: none; background: none; font-size: 16px;
344
+ color: #a1a1aa; padding: 2px 6px; cursor: pointer; }
345
+ .done { font-size: 13px; color: #18181b; margin: 0; }
346
+ input { font: inherit; font-size: 13px; width: 100%; box-sizing: border-box; padding: 8px 10px;
347
+ border: 1px solid #e4e4e7; border-radius: 8px; margin-bottom: 8px; }
348
+ `;
349
+ THANKS_MS = 1100;
350
+ }
351
+ });
352
+
202
353
  // src/capture.ts
203
354
  init_privacy();
204
355
  var TRAIL_MAX = 30;
@@ -573,6 +724,117 @@ function canPrompt(state, now, caps = DEFAULT_CAPS) {
573
724
  return true;
574
725
  }
575
726
 
727
+ // src/intervention.ts
728
+ var RENDERABLE = /* @__PURE__ */ new Set(["survey"]);
729
+ function str(v, max) {
730
+ if (typeof v !== "string") return void 0;
731
+ const t = v.trim();
732
+ return t ? t.slice(0, max) : void 0;
733
+ }
734
+ function buildServeContext(route, identity) {
735
+ const ctx = {};
736
+ const r = str(route, 500);
737
+ if (r) ctx.route = r;
738
+ const account = str(identity == null ? void 0 : identity.account, 200);
739
+ if (account) ctx.account = account;
740
+ const plan = str(identity == null ? void 0 : identity.plan, 80);
741
+ if (plan) ctx.plan = plan;
742
+ return ctx;
743
+ }
744
+ function normalizeServed(raw) {
745
+ if (!raw || typeof raw !== "object") return null;
746
+ const r = raw;
747
+ const id = str(r.id, 64);
748
+ const type = str(r.type, 40);
749
+ if (!id || !type || !RENDERABLE.has(type)) return null;
750
+ const cfg = r.config && typeof r.config === "object" ? r.config : {};
751
+ const question = str(cfg.question, 500);
752
+ if (!question) return null;
753
+ const options = Array.isArray(cfg.options) ? cfg.options.map((o) => str(o, 200)).filter((o) => !!o).slice(0, 6) : void 0;
754
+ const config = {
755
+ question,
756
+ ...options && options.length ? { options } : {},
757
+ allow_text: cfg.allow_text === true
758
+ };
759
+ return { id, type, config };
760
+ }
761
+ function pickRespondentKey(userId, persisted, sessionRef) {
762
+ const uid = str(userId, 200);
763
+ if (uid) return uid;
764
+ const p = str(persisted, 200);
765
+ if (p) return p;
766
+ return sessionRef;
767
+ }
768
+ var RK_STORAGE_KEY = "sightspool_rk";
769
+ function getRespondentKey(identity, sessionRef) {
770
+ const uid = str(identity == null ? void 0 : identity.userId, 200);
771
+ if (uid) return uid;
772
+ try {
773
+ let v = localStorage.getItem(RK_STORAGE_KEY);
774
+ if (!v) {
775
+ v = "anon-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
776
+ localStorage.setItem(RK_STORAGE_KEY, v);
777
+ }
778
+ return pickRespondentKey(void 0, v, sessionRef);
779
+ } catch (e) {
780
+ return sessionRef;
781
+ }
782
+ }
783
+ function serveUrl(endpoint, path) {
784
+ return `${endpoint.replace(/\/+$/, "")}/api/sdk/${path}`;
785
+ }
786
+ async function fetchIntervention(opts) {
787
+ var _a2;
788
+ try {
789
+ if (typeof fetch !== "function") return null;
790
+ const res = await fetch(serveUrl(opts.endpoint, "serve"), {
791
+ method: "POST",
792
+ headers: { "Content-Type": "text/plain" },
793
+ body: JSON.stringify({
794
+ key: opts.key,
795
+ context: opts.ctx,
796
+ respondent_key: opts.respondentKey
797
+ }),
798
+ mode: "cors",
799
+ credentials: "omit"
800
+ });
801
+ if (!res.ok) return null;
802
+ const data = await res.json();
803
+ const served = normalizeServed(data == null ? void 0 : data.intervention);
804
+ if (opts.debug) console.debug("[sightspool] serve", (_a2 = served == null ? void 0 : served.id) != null ? _a2 : "(none)");
805
+ return served;
806
+ } catch (e) {
807
+ return null;
808
+ }
809
+ }
810
+ function submitResponse(opts) {
811
+ try {
812
+ const url = serveUrl(opts.endpoint, "respond");
813
+ const json = JSON.stringify({
814
+ key: opts.key,
815
+ intervention_id: opts.interventionId,
816
+ response: opts.response,
817
+ respondent_key: opts.respondentKey
818
+ });
819
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
820
+ const blob = new Blob([json], { type: "text/plain" });
821
+ if (navigator.sendBeacon(url, blob)) return;
822
+ }
823
+ if (typeof fetch === "function") {
824
+ void fetch(url, {
825
+ method: "POST",
826
+ headers: { "Content-Type": "text/plain" },
827
+ body: json,
828
+ keepalive: true,
829
+ mode: "cors",
830
+ credentials: "omit"
831
+ }).catch(() => {
832
+ });
833
+ }
834
+ } catch (e) {
835
+ }
836
+ }
837
+
576
838
  // src/env.ts
577
839
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", ""]);
578
840
  function isLocalhost(hostname) {
@@ -585,6 +847,8 @@ function isLocalhost(hostname) {
585
847
  var DEFAULT_ENDPOINT = "https://app.sightspool.com";
586
848
  var FRICTION_EMIT_COOLDOWN_MS = 3e4;
587
849
  var EVENT_DEBOUNCE_MS = 600;
850
+ var SERVE_SETTLE_MS = 2500;
851
+ var MAX_INTERVENTIONS_PER_SESSION = 1;
588
852
  function currentHostname() {
589
853
  try {
590
854
  return typeof location !== "undefined" ? location.hostname : "";
@@ -649,9 +913,53 @@ async function maybePrompt(c, trigger) {
649
913
  c.egress.enqueue(buildSignal(c, trigger));
650
914
  }
651
915
  }
916
+ async function maybeServeIntervention(c) {
917
+ if (!c.running || !c.interventionsEnabled || c.suppressed) return;
918
+ if (c.surveyOnScreen) return;
919
+ if (c.interventionsShown >= MAX_INTERVENTIONS_PER_SESSION) return;
920
+ const now = Date.now();
921
+ if (c.fatigue.lastPromptAt !== null && now - c.fatigue.lastPromptAt < DEFAULT_CAPS.cooldownMs)
922
+ return;
923
+ const respondentKey = getRespondentKey(c.identity, c.sessionRef);
924
+ const served = await fetchIntervention({
925
+ endpoint: c.config.endpoint,
926
+ key: c.config.key,
927
+ ctx: buildServeContext(c.capture.route(), c.identity),
928
+ respondentKey,
929
+ debug: c.config.debug
930
+ });
931
+ if (!served || !c.running) return;
932
+ c.surveyOnScreen = true;
933
+ c.interventionsShown += 1;
934
+ c.fatigue.lastPromptAt = Date.now();
935
+ try {
936
+ const { showSurvey: showSurvey2 } = await Promise.resolve().then(() => (init_survey(), survey_exports));
937
+ const result = await showSurvey2(served.config);
938
+ if (!result.dismissed && (result.choice || result.text)) {
939
+ submitResponse({
940
+ endpoint: c.config.endpoint,
941
+ key: c.config.key,
942
+ interventionId: served.id,
943
+ response: { choice: result.choice, text: result.text },
944
+ respondentKey
945
+ });
946
+ }
947
+ } catch (e) {
948
+ } finally {
949
+ c.surveyOnScreen = false;
950
+ }
951
+ }
652
952
  function onCaptureEvent() {
653
953
  const c = ctrl;
654
954
  if (!c || !c.running) return;
955
+ try {
956
+ const route = c.capture.route();
957
+ if (route && route !== c.lastServeRoute) {
958
+ c.lastServeRoute = route;
959
+ void maybeServeIntervention(c);
960
+ }
961
+ } catch (e) {
962
+ }
655
963
  if (c.eventTimer !== null) return;
656
964
  c.eventTimer = (typeof window !== "undefined" ? window.setTimeout : setTimeout)(() => {
657
965
  c.eventTimer = null;
@@ -692,6 +1000,18 @@ function startRuntime(c) {
692
1000
  document.addEventListener("mouseout", onExitIntent);
693
1001
  } catch (e) {
694
1002
  }
1003
+ if (c.interventionsEnabled) {
1004
+ c.lastServeRoute = (() => {
1005
+ try {
1006
+ return c.capture.route();
1007
+ } catch (e) {
1008
+ return null;
1009
+ }
1010
+ })();
1011
+ (typeof window !== "undefined" ? window.setTimeout : setTimeout)(() => {
1012
+ void maybeServeIntervention(c);
1013
+ }, SERVE_SETTLE_MS);
1014
+ }
695
1015
  if (c.config.debug) console.debug("[sightspool] started", c.sessionRef);
696
1016
  }
697
1017
  function init(config) {
@@ -723,7 +1043,11 @@ function init(config) {
723
1043
  lastFrictionEmitAt: 0,
724
1044
  eventTimer: null,
725
1045
  running: false,
726
- suppressed: isLocalhost(currentHostname()) && config.captureOnLocalhost !== true
1046
+ suppressed: isLocalhost(currentHostname()) && config.captureOnLocalhost !== true,
1047
+ interventionsEnabled: config.interventions !== false,
1048
+ lastServeRoute: null,
1049
+ surveyOnScreen: false,
1050
+ interventionsShown: 0
727
1051
  };
728
1052
  if (config.consent !== false) startRuntime(ctrl);
729
1053
  } catch (err) {
@@ -796,6 +1120,7 @@ try {
796
1120
  redact: list("data-sightspool-redact"),
797
1121
  block: list("data-sightspool-block"),
798
1122
  captureOnLocalhost: (el == null ? void 0 : el.hasAttribute("data-sightspool-capture-localhost")) || void 0,
1123
+ interventions: (el == null ? void 0 : el.hasAttribute("data-sightspool-no-interventions")) ? false : void 0,
799
1124
  debug: (el == null ? void 0 : el.hasAttribute("data-sightspool-debug")) || void 0
800
1125
  });
801
1126
  }