@sightspool/sdk 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,6 +1,50 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.0 — prepared, unpublished
3
+ ## 0.5.0 — 12 September 2026
4
+
5
+ - **The privacy promise is narrowed, deliberately.** Through 0.4.2 `identify(userId)`
6
+ retained only the presence of an ID and never transmitted it. **From 0.5.0 the id is
7
+ sent**, over TLS, to your own workspace's Sightspool endpoint and only on the
8
+ `operation: "offer"` request, so a journey-linked research card can tell whether a
9
+ visitor is in the cohort its question is about (SIG-122). Sightspool hashes it
10
+ workspace-scoped on arrival and never stores it raw in any table, log or payload.
11
+ - The SDK holds the id in memory for the page's lifetime only — never sessionStorage,
12
+ localStorage, a cookie, a URL, a fragment or a log. The interview page is handed
13
+ nothing but the offer and device, as before.
14
+ - `identify()` treats any value that is not a non-empty string of at most 200
15
+ characters after trimming as unidentified, exactly like `identify(null)`. Over-length
16
+ ids are never truncated: a truncated id is a different person, and on a gated card
17
+ that invites the wrong one.
18
+ - A changed id invalidates any pending check and any minted offer, so switching
19
+ accounts never inherits the previous person's invitation. Re-stating the same id
20
+ leaves a live invitation alone.
21
+ - `all_visitors` with no `identify()` call sends exactly the payload it sent before.
22
+ - **Integration requirement.** Your product must call `posthog.identify()` with the
23
+ same id it passes Sightspool. An email on one side and an internal UUID on the other
24
+ never meet, and the result is a flat zero match rate that reads as an empty cohort.
25
+ - API contract change, so this is a minor bump. `@sightspool/react` peers widened to
26
+ `>=0.4.0 <0.6.0`.
27
+
28
+ ## 0.4.2 — prepared, unpublished
29
+
30
+ - Take the launcher's invitation title and subtitle from the approved copy the
31
+ server supplies, rendered as text.
32
+ - Recover an accepted session after reload from a tab-scoped marker, and open the
33
+ accepted thank-you receipt from the completed launcher.
34
+ - Exchange presentation and accepted/ended lifecycle messages with the panel
35
+ rather than a minimize message alone, still never interview text, capabilities
36
+ or audio.
37
+ - Accept `theme` on script installs via `data-sightspool-theme`.
38
+ - Keep the panel inside narrow scrollable viewports, and reveal it before
39
+ restoring keyboard focus.
40
+
41
+ ## 0.4.1 — published 10 September 2026
42
+
43
+ - Replace popup interview delivery with one persistent corner panel.
44
+ - Keep open sessions mounted across minimize and recruitment cleanup.
45
+ - Restrict iframe permissions and validate parent message origin/source.
46
+
47
+ ## 0.4.0 — published 7 September 2026
4
48
 
5
49
  - Replace the public capture entry with research invitation delivery.
6
50
  - Require an explicit all_visitors or signed_in audience; no fabricated visitor identity.
package/README.md CHANGED
@@ -4,8 +4,9 @@ Embed Sightspool research in your product. The SDK invites website visitors and
4
4
  owner-approved research during User Hours, then opens Sightspool for consent and
5
5
  the interview. It does not capture page content, behavior, form values or analytics.
6
6
 
7
- This is the **0.4.0 research API**, a deliberate change from the old capture SDK.
8
- Install the exact version below and review upgrades before deploying them.
7
+ This is the **research API introduced in 0.4.0**, a deliberate change from the old
8
+ capture SDK. Install the exact version below and review upgrades before deploying
9
+ them — 0.5.0 narrows a privacy promise, so read the CHANGELOG before taking it.
9
10
  Old capture code and documentation are archived in `docs/legacy`.
10
11
 
11
12
  Read the [data and security guide](https://www.sightspool.com/sdk/data-security),
@@ -15,7 +16,7 @@ inspect the [public source](https://github.com/sightspool/sdk), or try the
15
16
  ## npm
16
17
 
17
18
  ```sh
18
- npm install --save-exact @sightspool/sdk@0.4.0
19
+ npm install --save-exact @sightspool/sdk@0.5.0
19
20
  ```
20
21
 
21
22
  ```ts
@@ -38,9 +39,28 @@ Choose an explicit `audience` matching the approved interview plan:
38
39
  - `signed_in`: only users with a real signed-in session. Initialize in your authenticated app shell and call `identify(actualUserId)` after authentication resolves. Until then, no request or invitation is made. `identify(null)` immediately removes invitations on logout.
39
40
 
40
41
  There is no default audience. Missing or invalid audience configuration stays idle.
41
- `identify` retains only whether an ID is present; the ID itself is not stored or
42
- transmitted. Do not invent IDs for visitors. This client setting controls display;
43
- it does not authenticate a participant or change the server-approved research cohort.
42
+
43
+ `identify` sends the id you pass, over TLS, to your own workspace's Sightspool
44
+ endpoint, on the offer request only. Sightspool hashes it workspace-scoped on
45
+ arrival and never stores it raw in any table, log or payload. The SDK holds it in
46
+ memory for the page's lifetime and never writes it to storage, a cookie, a URL, a
47
+ fragment or a log. **Through 0.4.2 the id was never transmitted; from 0.5.0 it
48
+ is** — it is what lets a cohort-targeted card tell whether you are one of the
49
+ people its research question is about. Do not invent IDs for visitors. This client
50
+ setting controls display; it does not authenticate a participant or change the
51
+ server-approved research cohort.
52
+
53
+ An id that is not a non-empty string of at most 200 characters after trimming is
54
+ treated as unidentified, exactly like `identify(null)`. Over-length ids are never
55
+ truncated: a truncated id is a different person.
56
+
57
+ ## Matching your analytics identity
58
+
59
+ Cohort-targeted cards match against journey cohorts derived from PostHog. Your
60
+ product **must call `posthog.identify()` with the same id it passes Sightspool.**
61
+ If you identify PostHog with an email and Sightspool with an internal UUID, those
62
+ are different identities that never meet — the match rate is a flat zero that
63
+ looks exactly like an empty cohort rather than like a misconfiguration.
44
64
 
45
65
  ## Script tag
46
66
 
@@ -83,7 +103,7 @@ CDN or self-hosted install. npm defaults to `https://www.sightspool.com`.
83
103
  | Method | Behavior |
84
104
  | --- | --- |
85
105
  | `init({ key, audience, endpoint? })` | Start research for the explicit audience; UUID key required. Repeating the same configuration is idempotent. Changing it tears down the previous runtime. |
86
- | `identify(userId)` | Enable signed-in eligibility; only its boolean presence stays locally. `null` clears it; signed-in-only invitations disappear, all-visitors recruitment continues. |
106
+ | `identify(userId)` | Enable signed-in eligibility, and send that id on the offer request so a cohort-targeted card can match it. Held in memory only, never stored. `null` clears it — as does any value that is not a non-empty string of at most 200 characters after trimming. Signed-in-only invitations disappear; all-visitors recruitment continues. |
87
107
  | `pause()` | Pause recruitment, abort the current request and remove the launcher. |
88
108
  | `resume()` | Resume recruitment for the configured audience. Does not bypass approval or hours. |
89
109
  | `destroy()` | Remove the launcher, listeners and polling. An already opened interview is not silently ended. |
@@ -160,3 +180,40 @@ The npm and browser builds share `src/research.ts`. The Sightspool app copies
160
180
  `sdk.global.js` from this package and serves `research-widget.js` as a byte-identical
161
181
  alias for recent internal snippets. Neither build imports the former capture engine.
162
182
  See `docs/research-sdk-transition.md` for release sequencing. Apache-2.0.
183
+
184
+ ## Embedded interview panel
185
+
186
+ An available invitation opens one bottom-right panel on the product page. Consent,
187
+ waiting, supported founder audio or Sightspool text conversation, and completion
188
+ remain inside its isolated Sightspool iframe. Minimize or Escape hides the panel;
189
+ Return to interview reopens the same session. Minimize does not mute, end or withdraw.
190
+ Use the explicit in-panel controls to stop audio or delete interview evidence.
191
+
192
+ Allow the configured Sightspool origin in your site's `frame-src` policy and
193
+ permit microphone delegation to that origin if founder audio is used. Preserve
194
+ other CSP and Permissions Policy restrictions; a host policy may intentionally
195
+ block audio. The iframe requests a microphone only after explicit participant action.
196
+ The frame is restricted to the workspace's saved product origin and exchanges only
197
+ presentation and accepted/ended lifecycle messages with its parent, never interview text, capabilities or audio. Interviews stay in the panel. Privacy and Sightspool attribution links open a separate tab.
198
+
199
+ Once opened, the panel stays mounted across visibility changes, pause, identity
200
+ changes and SDK destroy/remount, so recruitment cleanup cannot silently end a call.
201
+ Destroy stops further recruitment. The active panel remains reachable until the
202
+ page is left. A full document navigation interrupts media. A tab-scoped marker
203
+ restores the saved interview after reload; the app validates the saved capability
204
+ and a participant must explicitly resume before enabling a microphone. This does
205
+ not promise uninterrupted audio or a founder-phone reconnect grace period.
206
+
207
+ The launcher uses the approved invitation duration and thank-you label supplied by
208
+ Sightspool. Dismissal leaves a small logo button: the first click restores the
209
+ message and the second opens the interview. After completion or early stopping,
210
+ the small checked button opens the accepted thank-you receipt directly. The app
211
+ minimises after eight seconds once it is safe to close the recording UI. Claim
212
+ instructions come from the frozen interview terms; the SDK issues no rewards.
213
+ Tab restoration supplements the server's contact and capacity rules.
214
+
215
+ Placement is bottom-right on desktop and bottom-centre up to 560px, with safe-area
216
+ spacing and reduced-motion support. Set `theme: "light" | "dark" | "auto"` in
217
+ `init()` or `data-sightspool-theme` on a script installation; the default is dark.
218
+ Another bottom-right customer widget may require coordinated placement; configurable
219
+ offsets are not supplied.
package/dist/index.cjs CHANGED
@@ -3,16 +3,27 @@
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  // src/research.ts
6
+ var MAX_IDENTITY_LENGTH = 200;
6
7
  var slot = /* @__PURE__ */ Symbol.for("sightspool.research.runtime.v1");
7
8
  var host = () => typeof window === "undefined" ? null : window;
8
9
  var current = () => {
9
10
  var _a;
10
11
  return (_a = host()) == null ? void 0 : _a[slot];
11
12
  };
12
- var eligible = (r) => r.audience === "all_visitors" || r.identified;
13
+ var markerKey = (r) => "sightspool-widget-session:" + r.key;
14
+ function saveMarker(r, value) {
15
+ try {
16
+ sessionStorage.setItem(markerKey(r), value);
17
+ } catch (e) {
18
+ }
19
+ }
20
+ var eligible = (r) => r.audience === "all_visitors" || r.identity !== null;
13
21
  function remove(r) {
14
22
  var _a;
15
- (_a = r.button) == null ? void 0 : _a.remove();
23
+ if (r.frame || r.restoring) return;
24
+ (_a = r.shell) == null ? void 0 : _a.remove();
25
+ r.shell = null;
26
+ r.dismiss = null;
16
27
  r.button = null;
17
28
  r.offer = null;
18
29
  }
@@ -26,13 +37,254 @@ function invalidate(r) {
26
37
  function status(r, value) {
27
38
  r.status = value;
28
39
  }
40
+ function renderLauncher(r) {
41
+ var _a, _b, _c, _d, _e;
42
+ if (!r.button) return;
43
+ const iconOnly = r.completed || r.dismissed && !r.expanded;
44
+ r.button.className = "ss-launcher" + (iconOnly ? " ss-iconOnly" : !r.frame && r.attention ? " ss-attention" : "");
45
+ const fresh = r.button.children.length === 0;
46
+ const mark = (_a = r.button.children[0]) != null ? _a : document.createElement("span");
47
+ mark.className = "ss-launcherMark";
48
+ if (fresh) {
49
+ const logo = document.createElement("img");
50
+ logo.src = r.endpoint + "/sightspool-monomark-dark-bg.svg";
51
+ logo.width = 29;
52
+ logo.height = 29;
53
+ logo.alt = "";
54
+ mark.appendChild(logo);
55
+ }
56
+ const copy = (_b = r.button.children[1]) != null ? _b : document.createElement("span");
57
+ copy.className = "ss-launcherCopy";
58
+ copy.setAttribute("aria-hidden", String(iconOnly));
59
+ const title = (_c = copy.children[0]) != null ? _c : document.createElement("strong");
60
+ title.textContent = r.completed ? "Your thank-you" : r.expanded ? "Minimise conversation" : r.frame ? "Back to your conversation" : r.copy.title;
61
+ const subtitle = (_d = copy.children[1]) != null ? _d : document.createElement("small");
62
+ subtitle.textContent = r.completed ? "Your accepted offer is saved here" : r.frame ? "Return to your interview" : r.copy.subtitle;
63
+ if (fresh) {
64
+ copy.appendChild(title);
65
+ copy.appendChild(subtitle);
66
+ }
67
+ const arrow = (_e = r.button.children[2]) != null ? _e : document.createElement("span");
68
+ arrow.className = "ss-launcherAction";
69
+ arrow.textContent = r.expanded ? "\u2212" : "\u2192";
70
+ arrow.setAttribute("aria-hidden", "true");
71
+ if (fresh) {
72
+ r.button.appendChild(mark);
73
+ r.button.appendChild(copy);
74
+ r.button.appendChild(arrow);
75
+ }
76
+ r.button.setAttribute("aria-label", r.completed ? "View your accepted thank-you" : iconOnly ? "Show invitation message" : title.textContent + ". " + subtitle.textContent);
77
+ if (r.completed && !r.button.children[3]) {
78
+ const check2 = document.createElement("span");
79
+ check2.className = "ss-receiptCheck";
80
+ check2.textContent = "\u2713";
81
+ check2.setAttribute("aria-hidden", "true");
82
+ r.button.appendChild(check2);
83
+ }
84
+ if (r.dismiss) {
85
+ r.dismiss.disabled = Boolean(r.frame || r.dismissed);
86
+ r.dismiss.setAttribute("data-visible", String(!r.frame && !r.dismissed));
87
+ }
88
+ }
89
+ function mountLauncher(r) {
90
+ if (r.button) {
91
+ renderLauncher(r);
92
+ return;
93
+ }
94
+ const shell = document.createElement("aside");
95
+ shell.className = "ss-widgetPosition";
96
+ shell.setAttribute("data-theme", r.theme);
97
+ shell.setAttribute("aria-label", "Sightspool research");
98
+ const style = document.createElement("style");
99
+ style.textContent = `.ss-widgetPosition { position: fixed; bottom: max(22px,env(safe-area-inset-bottom)); right: max(24px,env(safe-area-inset-right)); z-index: 60; width: min(366px,calc(100vw - 32px)); pointer-events: none; }
100
+ .ss-widgetPosition > section[data-open="true"], .ss-launcher, .ss-dismissInvitation[data-visible="true"] { pointer-events: auto; }
101
+ .ss-launcherRow { display: flex; align-items: center; justify-content: flex-end; margin-top: 12px; }
102
+ .ss-launcher { position: relative; width: max-content; max-width: 100%; interpolate-size: allow-keywords; height: 64px; min-height: 54px; display: grid; grid-template-columns: 44px minmax(0,1fr) 18px; align-items: center; gap: 12px; padding: 8px 19px 8px 9px; color: #fff; background: #18181b; border: 1px solid #ffffff20; border-radius: 999px; box-shadow: 0 8px 32px #18101d30,0 2px 6px #18101d18; text-align: left; transition: width 460ms cubic-bezier(.22,1,.36,1), height 360ms cubic-bezier(.22,1,.36,1), padding 460ms cubic-bezier(.22,1,.36,1), gap 460ms cubic-bezier(.22,1,.36,1), grid-template-columns 460ms cubic-bezier(.22,1,.36,1), transform 180ms, box-shadow 240ms; }
103
+ .ss-launcherGroup { position: relative; max-width: 100%; }
104
+ .ss-dismissInvitation { position: absolute; top: -12px; right: 0; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid #dfdbe0; border-radius: 50%; background: #fcfaf9; color: #6e6470; box-shadow: 0 2px 8px #18101d15; opacity: 0; transform: scale(.7); visibility: hidden; transition: opacity 140ms, transform 240ms, visibility 0s 240ms; }
105
+ .ss-dismissInvitation[data-visible="true"] { opacity: 1; transform: scale(1); visibility: visible; transition-delay: 220ms,220ms,0s; }
106
+ .ss-dismissInvitation:hover { color: #241e25; background: #eee8ed; }
107
+ .ss-launcher.ss-attention { animation: invitation-enter 450ms cubic-bezier(.2,.8,.2,1) both, invitation-glow 2400ms 450ms ease-in-out; }
108
+ @keyframes invitation-enter { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } }
109
+ @keyframes invitation-glow { 0%,100% { box-shadow: 0 8px 32px #18101d30,0 0 0 0 #c6fa6400; } 25%,70% { box-shadow: 0 8px 32px #18101d30,0 0 0 5px #c6fa6440,0 0 28px #a8176830; } 48% { box-shadow: 0 8px 32px #18101d30,0 0 0 1px #c6fa6410; } }
110
+ .ss-launcher.ss-iconOnly { position: relative; min-height: 54px; width: 54px; height: 54px; padding: 4px; gap: 0; grid-template-columns: 44px minmax(0,0fr) 0px; box-shadow: 0 3px 14px #18101d20; }
111
+ .ss-compactActiveBadge { position: absolute; right: 1px; top: 1px; width: 10px; height: 10px; border: 2px solid #18181b; border-radius: 50%; background: #c6fa64; }
112
+ .ss-launcher::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; box-shadow: -5px 0 20px #a8176830,5px 0 18px #c6fa641c,inset 0 0 0 1px #ffffff12; transition: opacity 280ms ease; }
113
+ @media (hover: hover) {
114
+ .ss-launcher:not(.ss-iconOnly):hover::after { opacity: 1; animation: hover-halo 2200ms ease-in-out infinite; }
115
+ .ss-launcher:not(.ss-iconOnly):hover .ss-launcherMark::before { animation: hover-logo-glow 2200ms ease-in-out infinite; }
116
+ }
117
+ .ss-launcher:focus-visible::after { opacity: 1; }
118
+ @keyframes hover-halo { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
119
+ @keyframes hover-logo-glow { 0%,100% { opacity: .925; transform: scale(1); } 50% { opacity: 1; transform: scale(1.08); filter: brightness(1.18); } }
120
+ .ss-launcherMark { position: relative; isolation: isolate; display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border-radius: 50%; }
121
+ .ss-launcherMark::before { content: ""; position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; background: radial-gradient(ellipse at 30% 30%,rgb(168 23 104 / 87%),transparent 72%),radial-gradient(ellipse at 74% 68%,rgb(198 250 100 / 50%),transparent 70%); mask-image: radial-gradient(closest-side,#000 59%,transparent 100%); opacity: .925; }
122
+ .ss-launcherMark img { position: relative; }
123
+ .ss-launcherCopy { min-width: 0; overflow: hidden; white-space: nowrap; opacity: 1; transform: translateX(0); transition: opacity 200ms 100ms, transform 360ms cubic-bezier(.22,1,.36,1); }
124
+ .ss-iconOnly .ss-launcherCopy { opacity: 0; transform: translateX(12px); transition-delay: 0s; }
125
+ .ss-launcherAction { display: grid; place-items: center; overflow: hidden; color: #c6fa64; opacity: 1; transition: opacity 180ms 140ms, transform 360ms; }
126
+ .ss-iconOnly .ss-launcherAction { opacity: 0; transform: translateX(8px); transition-delay: 0s; }
127
+ .ss-launcherCopy strong { display: block; font-size: 13px; font-weight: 600; line-height: 1.4; }
128
+ .ss-launcherCopy small { display: block; margin-top: 4px; font-size: 12px; color: #d5d0d8; line-height: 1.4; }
129
+ .ss-launcher > svg { flex-shrink: 0; color: #c6fa64; }
130
+ .ss-activeBadge { flex-shrink: 0; width: 8px; height: 8px; background: #c6fa64; border-radius: 50%; box-shadow: 0 0 0 4px #c6fa6418; }
131
+
132
+ .ss-widgetPosition{z-index:2147483000;font:14px Arial,Helvetica,sans-serif;line-height:1.5}
133
+ .ss-widgetPosition *{box-sizing:border-box}
134
+ .ss-widgetPosition button{font:inherit;cursor:pointer;outline-offset:4px}
135
+ .ss-panel{position:absolute;bottom:calc(100% + 12px);right:0;width:100%;height:610px;max-height:calc(100dvh - max(22px,env(safe-area-inset-bottom)) - 104px);border:1px solid #e7e0e6;border-radius:20px;background:#fcfaf9;color:#241e25;box-shadow:0 16px 64px #18101d26;overflow:hidden;display:flex;flex-direction:column;transform-origin:bottom right;opacity:0;visibility:hidden;transform:translateY(18px) scale(.82,.2);transition:transform 340ms cubic-bezier(.4,0,.6,1),opacity 180ms,visibility 0s 340ms;pointer-events:none}
136
+ .ss-panel[data-open="true"]{opacity:1;visibility:visible;transform:translateY(0) scale(1);transition:transform 480ms cubic-bezier(.16,1,.3,1),opacity 200ms,visibility 0s;pointer-events:auto}
137
+ .ss-panel>div,.ss-panel>iframe{opacity:0;transition:opacity 120ms}
138
+ .ss-panel[data-open="true"]>div,.ss-panel[data-open="true"]>iframe{opacity:1;transition:opacity 240ms 170ms}
139
+ .ss-panel>div{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid #e7e0e6}
140
+ .ss-panel>div>button{border:0;background:transparent;color:#554b58;padding:8px;border-radius:8px}
141
+ .ss-panel iframe{display:block;width:100%;flex:1;min-height:0;border:0;background:#fcfaf9}
142
+ [data-theme="dark"] .ss-panel{background:#18181b;color:#fff;border-color:#ffffff1c}
143
+ [data-theme="dark"] .ss-panel>div{border-color:#ffffff1c}
144
+ [data-theme="dark"] .ss-panel>div>button{color:#cbc5cd}
145
+ @media(prefers-color-scheme:dark){[data-theme="auto"] .ss-panel{background:#18181b;color:#fff;border-color:#ffffff1c}[data-theme="auto"] .ss-panel>div{border-color:#ffffff1c}[data-theme="auto"] .ss-panel>div>button{color:#cbc5cd}}
146
+ .ss-receiptCheck{position:absolute;right:0;top:0;display:grid;place-items:center;width:16px;height:16px;border:2px solid #18181b;border-radius:50%;background:#c6fa64;color:#18181b;font-size:10px}
147
+ @media(max-width:560px){.ss-widgetPosition{bottom:max(12px,env(safe-area-inset-bottom));right:auto;left:50%;transform:translateX(-50%);width:min(366px,calc(100vw - 24px))}.ss-panel{max-height:calc(100dvh - max(12px,env(safe-area-inset-bottom)) - 100px);transform-origin:bottom center}.ss-launcher{gap:10px;padding-right:15px;height:80px}.ss-launcherCopy{white-space:normal}.ss-launcherRow{justify-content:center}}
148
+ @media(prefers-reduced-motion:reduce){.ss-widgetPosition *,.ss-widgetPosition *::before,.ss-widgetPosition *::after{animation:none!important;transition:none!important}}
149
+ `;
150
+ shell.appendChild(style);
151
+ const row = document.createElement("div");
152
+ row.className = "ss-launcherRow";
153
+ const group = document.createElement("div");
154
+ group.className = "ss-launcherGroup";
155
+ const button = document.createElement("button");
156
+ button.type = "button";
157
+ const dismiss = document.createElement("button");
158
+ dismiss.type = "button";
159
+ dismiss.className = "ss-dismissInvitation";
160
+ dismiss.textContent = "\xD7";
161
+ dismiss.setAttribute("aria-label", "Dismiss invitation and keep a small button");
162
+ dismiss.onclick = () => {
163
+ r.attention = false;
164
+ r.dismissed = true;
165
+ try {
166
+ sessionStorage.setItem("sightspool-widget-dismissed:" + r.key, "1");
167
+ } catch (e) {
168
+ }
169
+ renderLauncher(r);
170
+ button.focus();
171
+ };
172
+ button.onanimationend = (event) => {
173
+ if (event.animationName.includes("invitation-glow")) {
174
+ r.attention = false;
175
+ renderLauncher(r);
176
+ }
177
+ };
178
+ button.onclick = () => {
179
+ if (r.completed || r.frame || r.restoring) {
180
+ if (r.frame) expand(r, !r.expanded);
181
+ else openPanel(r);
182
+ return;
183
+ }
184
+ if (r.dismissed) {
185
+ r.attention = false;
186
+ r.dismissed = false;
187
+ try {
188
+ sessionStorage.setItem("sightspool-widget-dismissed:" + r.key, "0");
189
+ } catch (e) {
190
+ }
191
+ renderLauncher(r);
192
+ return;
193
+ }
194
+ if (!r.disposed && !r.paused && eligible(r)) openPanel(r);
195
+ };
196
+ group.appendChild(button);
197
+ group.appendChild(dismiss);
198
+ row.appendChild(group);
199
+ shell.appendChild(row);
200
+ r.shell = shell;
201
+ r.button = button;
202
+ r.dismiss = dismiss;
203
+ document.body.appendChild(shell);
204
+ renderLauncher(r);
205
+ }
206
+ function expand(r, expanded) {
207
+ var _a;
208
+ if (!r.panel || !r.frame || !r.button) return;
209
+ if (r.expanded === expanded) return;
210
+ const ownedFocus = r.panel.contains(document.activeElement);
211
+ r.expanded = expanded;
212
+ r.panel.setAttribute("data-open", String(expanded));
213
+ r.panel.setAttribute("aria-hidden", String(!expanded));
214
+ r.panel.inert = !expanded;
215
+ r.button.setAttribute("aria-expanded", String(expanded));
216
+ renderLauncher(r);
217
+ if (expanded) (_a = r.panel.querySelector("button")) == null ? void 0 : _a.focus();
218
+ else if (ownedFocus) r.button.focus();
219
+ }
220
+ function openPanel(r) {
221
+ if (r.frame) {
222
+ expand(r, true);
223
+ return;
224
+ }
225
+ if (!r.offer && !r.restoring || !r.button || !r.shell) return;
226
+ const panel = document.createElement("section");
227
+ panel.className = "ss-panel";
228
+ panel.id = "sightspool-interview-panel";
229
+ panel.setAttribute("role", "dialog");
230
+ panel.setAttribute("aria-label", "Sightspool interview");
231
+ const header = document.createElement("div");
232
+ const title = document.createElement("strong");
233
+ title.textContent = "Sightspool";
234
+ const minimize = document.createElement("button");
235
+ minimize.type = "button";
236
+ minimize.textContent = "Minimise";
237
+ const hide = () => expand(r, false);
238
+ minimize.onclick = hide;
239
+ header.appendChild(title);
240
+ header.appendChild(minimize);
241
+ panel.appendChild(header);
242
+ const frame = document.createElement("iframe");
243
+ frame.title = "Sightspool research conversation";
244
+ frame.allow = "microphone; autoplay; clipboard-write";
245
+ frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
246
+ frame.referrerPolicy = "no-referrer";
247
+ const url = new URL(r.endpoint + "/interview-widget");
248
+ url.searchParams.set("key", r.key);
249
+ url.searchParams.set("theme", r.theme);
250
+ url.hash = new URLSearchParams(r.restoring ? { device: r.device, restore: "1" } : { offer: r.offer, device: r.device }).toString();
251
+ frame.src = url.href;
252
+ panel.appendChild(frame);
253
+ panel.onkeydown = (event) => {
254
+ if (event.key === "Escape") {
255
+ event.preventDefault();
256
+ hide();
257
+ }
258
+ };
259
+ r.panel = panel;
260
+ r.frame = frame;
261
+ r.message = (event) => {
262
+ var _a, _b, _c;
263
+ if (event.source !== frame.contentWindow || event.origin !== r.endpoint) return;
264
+ if (((_a = event.data) == null ? void 0 : _a.type) === "sightspool:panel:minimize") hide();
265
+ if (((_b = event.data) == null ? void 0 : _b.type) === "sightspool:interview:accepted") {
266
+ saveMarker(r, "active");
267
+ }
268
+ if (((_c = event.data) == null ? void 0 : _c.type) === "sightspool:interview:ended") {
269
+ r.completed = true;
270
+ saveMarker(r, "ended");
271
+ renderLauncher(r);
272
+ }
273
+ };
274
+ window.addEventListener("message", r.message);
275
+ r.button.setAttribute("aria-controls", panel.id);
276
+ r.shell.appendChild(panel);
277
+ expand(r, true);
278
+ }
29
279
  async function check(r) {
30
- if (r.disposed || r.paused || !eligible(r) || r.pending || document.visibilityState !== "visible") return;
280
+ if (r.frame || r.restoring || r.disposed || r.paused || !eligible(r) || r.pending || document.visibilityState !== "visible") return;
31
281
  const generation = r.generation;
32
282
  const request = new AbortController();
33
283
  r.pending = request;
34
284
  const timeout = window.setTimeout(() => request.abort(), 1e4);
35
285
  if (!r.button) status(r, "checking");
286
+ const payload = { operation: "offer", key: r.key, device: r.device };
287
+ if (r.identity) payload.identity = r.identity;
36
288
  try {
37
289
  const response = await fetch(r.endpoint + "/widget-offer", {
38
290
  method: "POST",
@@ -40,7 +292,7 @@ async function check(r) {
40
292
  cache: "no-store",
41
293
  referrerPolicy: "no-referrer",
42
294
  headers: { "Content-Type": "application/json" },
43
- body: JSON.stringify({ operation: "offer", key: r.key, device: r.device }),
295
+ body: JSON.stringify(payload),
44
296
  signal: request.signal
45
297
  });
46
298
  if (!response.ok) throw Error("offer unavailable");
@@ -53,26 +305,10 @@ async function check(r) {
53
305
  }
54
306
  r.offer = result.offer;
55
307
  status(r, "available");
56
- if (r.button) return;
57
- const button = document.createElement("button");
58
- button.type = "button";
59
- button.textContent = "Talk to the founder \xB7 5 min";
60
- button.setAttribute("aria-label", "Sightspool: join a five-minute user interview");
61
- button.style.cssText = "position:fixed;bottom:20px;right:20px;z-index:2147483000;padding:14px 18px;border:0;border-radius:16px;background:#171717;color:white;font:500 14px system-ui;box-shadow:0 8px 30px #0003;cursor:pointer;max-width:calc(100vw - 40px)";
62
- button.onclick = () => {
63
- try {
64
- if (r.disposed || r.paused || !eligible(r) || !r.offer) return;
65
- if (r.popup && !r.popup.closed) {
66
- r.popup.focus();
67
- return;
68
- }
69
- const hash = new URLSearchParams({ offer: r.offer, device: r.device });
70
- r.popup = window.open(r.endpoint + "/interview-widget#" + hash, "sightspool-interview", "popup,width=520,height=760");
71
- } catch (e) {
72
- }
73
- };
74
- r.button = button;
75
- document.body.appendChild(button);
308
+ if (result.invitation && typeof result.invitation.title === "string" && typeof result.invitation.subtitle === "string") {
309
+ r.copy = { title: result.invitation.title.slice(0, 200), subtitle: result.invitation.subtitle.slice(0, 240) };
310
+ }
311
+ mountLauncher(r);
76
312
  } catch (e) {
77
313
  if (!r.disposed && generation === r.generation) {
78
314
  remove(r);
@@ -93,6 +329,7 @@ function init(config) {
93
329
  if (url.username || url.password) return;
94
330
  const previous = current();
95
331
  if (previous && previous.key === config.key && previous.endpoint === url.origin && previous.audience === config.audience) return;
332
+ if ((previous == null ? void 0 : previous.frame) || (previous == null ? void 0 : previous.restoring)) return;
96
333
  if (previous) destroy();
97
334
  let device = "";
98
335
  try {
@@ -108,24 +345,46 @@ function init(config) {
108
345
  }
109
346
  }
110
347
  const r = {
348
+ theme: config.theme === "light" || config.theme === "auto" ? config.theme : "dark",
111
349
  key: config.key,
112
350
  endpoint: url.origin,
113
351
  audience: config.audience,
114
- identified: false,
352
+ identity: null,
115
353
  paused: false,
354
+ attention: true,
355
+ dismissed: false,
356
+ completed: false,
357
+ restoring: false,
358
+ copy: { title: "Share your experience", subtitle: "A research conversation" },
359
+ shell: null,
360
+ dismiss: null,
116
361
  disposed: false,
117
362
  generation: 0,
118
363
  device,
119
364
  offer: null,
120
365
  button: null,
121
- popup: null,
366
+ panel: null,
367
+ frame: null,
368
+ expanded: false,
369
+ message: null,
122
370
  pending: null,
123
371
  timer: 0,
124
372
  visibility: () => {
125
373
  },
126
374
  status: "signed_out"
127
375
  };
376
+ try {
377
+ r.dismissed = sessionStorage.getItem("sightspool-widget-dismissed:" + r.key) === "1";
378
+ const marker = sessionStorage.getItem(markerKey(r));
379
+ r.restoring = marker === "active" || marker === "ended";
380
+ r.completed = marker === "ended";
381
+ } catch (e) {
382
+ }
128
383
  browser[slot] = r;
384
+ if (r.restoring) {
385
+ mountLauncher(r);
386
+ return;
387
+ }
129
388
  r.visibility = () => {
130
389
  try {
131
390
  if (document.visibilityState === "visible") void check(r);
@@ -148,13 +407,14 @@ function identify(userId) {
148
407
  try {
149
408
  const r = current();
150
409
  if (!r) return;
151
- const identified = typeof userId === "string" && userId.trim().length > 0;
152
- if (!identified && !r.identified) {
410
+ const trimmed = typeof userId === "string" ? userId.trim() : "";
411
+ const identity = trimmed.length > 0 && trimmed.length <= MAX_IDENTITY_LENGTH ? trimmed : null;
412
+ if (identity === r.identity) {
153
413
  if (!r.paused && eligible(r)) void check(r);
154
414
  return;
155
415
  }
156
416
  invalidate(r);
157
- r.identified = identified;
417
+ r.identity = identity;
158
418
  status(r, r.paused ? "paused" : eligible(r) ? "unavailable" : "signed_out");
159
419
  if (eligible(r) && !r.paused) void check(r);
160
420
  } catch (e) {
@@ -190,7 +450,7 @@ function destroy() {
190
450
  invalidate(r);
191
451
  window.clearInterval(r.timer);
192
452
  document.removeEventListener("visibilitychange", r.visibility);
193
- delete host()[slot];
453
+ if (!r.frame && !r.restoring) delete host()[slot];
194
454
  } catch (e) {
195
455
  }
196
456
  }