@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 +45 -1
- package/README.md +64 -7
- package/dist/index.cjs +290 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +290 -30
- package/dist/index.js.map +1 -1
- package/dist/release.json +9 -9
- package/dist/sdk.global.js +52 -2
- package/dist/sdk.global.js.map +1 -1
- package/dist/source.json +3 -3
- package/package.json +1 -1
- package/site/demo.js +10 -1
- package/site/index.html +15 -5
- package/site/releases/0.4.0/781ab339e13fe18b86b56cc9d84dd308bebf6482a885c8aae46a8b3b84801f6e/release.json +21 -0
- package/site/releases/0.4.0/781ab339e13fe18b86b56cc9d84dd308bebf6482a885c8aae46a8b3b84801f6e/sdk.global.js +3 -0
- package/site/releases/0.4.0/781ab339e13fe18b86b56cc9d84dd308bebf6482a885c8aae46a8b3b84801f6e/sdk.global.js.map +1 -0
- package/site/releases/0.4.0/781ab339e13fe18b86b56cc9d84dd308bebf6482a885c8aae46a8b3b84801f6e/source.json +11 -0
- package/site/releases/0.4.1/116419831a4c93513a867dcb4d3b808e8802ee27f5ad732460c997cefb8c804b/release.json +21 -0
- package/site/releases/0.4.1/116419831a4c93513a867dcb4d3b808e8802ee27f5ad732460c997cefb8c804b/sdk.global.js +3 -0
- package/site/releases/0.4.1/116419831a4c93513a867dcb4d3b808e8802ee27f5ad732460c997cefb8c804b/sdk.global.js.map +1 -0
- package/site/releases/0.4.1/116419831a4c93513a867dcb4d3b808e8802ee27f5ad732460c997cefb8c804b/source.json +11 -0
- package/site/trust.html +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
|
8
|
-
Install the exact version below and review upgrades before deploying
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
-
(
|
|
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(
|
|
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 (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
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.
|
|
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
|
}
|