@loupfeed/core 0.1.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/dist/index.cjs ADDED
@@ -0,0 +1,942 @@
1
+ 'use strict';
2
+
3
+ // src/dsn.ts
4
+ var PATH_RE = /^\/o\/([^/]+)\/p\/([^/]+)\/?$/;
5
+ function parseDsn(dsn) {
6
+ let url;
7
+ try {
8
+ url = new URL(dsn);
9
+ } catch {
10
+ throw new Error(`[loupfeed] Invalid DSN: ${JSON.stringify(dsn)}`);
11
+ }
12
+ const publicKey = url.username;
13
+ if (!publicKey) {
14
+ throw new Error("[loupfeed] DSN is missing its public key: https://<PUBLIC_KEY>@host/o/<org>/p/<project>");
15
+ }
16
+ const match = PATH_RE.exec(url.pathname);
17
+ const orgId = match?.[1];
18
+ const projectId = match?.[2];
19
+ if (!orgId || !projectId) {
20
+ throw new Error(`[loupfeed] DSN path must be /o/<org>/p/<project>, got ${JSON.stringify(url.pathname)}`);
21
+ }
22
+ const protocol = url.protocol.replace(/:$/, "");
23
+ return {
24
+ raw: dsn,
25
+ protocol,
26
+ publicKey,
27
+ host: url.hostname,
28
+ port: url.port,
29
+ orgId,
30
+ projectId,
31
+ baseUrl: `${protocol}://${url.host}`
32
+ };
33
+ }
34
+ function apiBase(dsn) {
35
+ return `${dsn.baseUrl}/api/${encodeURIComponent(dsn.orgId)}/${encodeURIComponent(dsn.projectId)}`;
36
+ }
37
+ function eventsUrl(dsn) {
38
+ return `${apiBase(dsn)}/events`;
39
+ }
40
+ function replaysUrl(dsn) {
41
+ return `${apiBase(dsn)}/replays`;
42
+ }
43
+ function manifestsUrl(dsn) {
44
+ return `${apiBase(dsn)}/manifests`;
45
+ }
46
+ function resolveUrl(dsn) {
47
+ return `${apiBase(dsn)}/resolve`;
48
+ }
49
+
50
+ // src/transport.ts
51
+ function delay(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+ function makeFetchTransport(dsn, opts = {}) {
55
+ const headers = {
56
+ "content-type": "application/json",
57
+ // Non-secret, project-scoped ingest key (doc 06 §Auth on ingest).
58
+ "x-loupfeed-key": dsn.publicKey
59
+ };
60
+ const pending = /* @__PURE__ */ new Set();
61
+ async function post(url, body, attempt = 0) {
62
+ try {
63
+ const res = await fetch(url, { method: "POST", headers, body, keepalive: true });
64
+ if (res.status >= 500 && attempt < 2) {
65
+ await delay(200 * (attempt + 1));
66
+ return post(url, body, attempt + 1);
67
+ }
68
+ return res;
69
+ } catch (err) {
70
+ if (attempt < 2) {
71
+ await delay(200 * (attempt + 1));
72
+ return post(url, body, attempt + 1);
73
+ }
74
+ throw err;
75
+ }
76
+ }
77
+ function track(p) {
78
+ pending.add(p);
79
+ void p.catch(() => void 0).finally(() => pending.delete(p));
80
+ return p;
81
+ }
82
+ async function send(event) {
83
+ return track(
84
+ post(eventsUrl(dsn), JSON.stringify(event)).then(async (res) => {
85
+ if (!res.ok) {
86
+ const text = await res.text().catch(() => "");
87
+ throw new Error(`[loupfeed] ingest responded ${res.status} ${text}`.trim());
88
+ }
89
+ const data = await res.json().catch(() => ({}));
90
+ if (opts.debug) console.debug("[loupfeed] sent event", data.eventId ?? event.eventId);
91
+ return data.eventId ?? event.eventId;
92
+ })
93
+ );
94
+ }
95
+ async function sendReplay(buffer2) {
96
+ const body = JSON.stringify({
97
+ replayId: buffer2.replayId,
98
+ durationMs: buffer2.durationMs,
99
+ kind: buffer2.kind ?? "rrweb",
100
+ events: buffer2.events,
101
+ frames: buffer2.frames,
102
+ viewport: buffer2.viewport,
103
+ video: buffer2.video,
104
+ mimeType: buffer2.mimeType
105
+ });
106
+ const res = await post(replaysUrl(dsn), body);
107
+ if (!res.ok) {
108
+ const text = await res.text().catch(() => "");
109
+ throw new Error(`[loupfeed] replay upload ${res.status} ${text}`.trim());
110
+ }
111
+ const data = await res.json().catch(() => ({}));
112
+ return {
113
+ replayId: data.replayId ?? buffer2.replayId,
114
+ durationMs: data.durationMs ?? buffer2.durationMs
115
+ };
116
+ }
117
+ async function flush(timeout) {
118
+ const all = Promise.allSettled([...pending]).then(() => true);
119
+ if (!timeout) return all;
120
+ return Promise.race([all, delay(timeout).then(() => false)]);
121
+ }
122
+ return { send, sendReplay, flush };
123
+ }
124
+
125
+ // src/selector.ts
126
+ var rootEl = null;
127
+ function setSelectorRoot(el) {
128
+ rootEl = el;
129
+ }
130
+ function getSelectorRoot() {
131
+ return rootEl;
132
+ }
133
+ function defaultRoot() {
134
+ if (rootEl) return rootEl;
135
+ return typeof document !== "undefined" ? document.body : null;
136
+ }
137
+ function selectorFor(el) {
138
+ const tagged = el.closest("[data-fb-id]");
139
+ if (tagged) {
140
+ return `[data-fb-id="${tagged.getAttribute("data-fb-id")}"]`;
141
+ }
142
+ const root = defaultRoot();
143
+ const parts = [];
144
+ let cur = el;
145
+ while (cur && cur !== root) {
146
+ const node = cur;
147
+ const parent = node.parentElement;
148
+ if (!parent) break;
149
+ const tag = node.tagName.toLowerCase();
150
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
151
+ let segment = tag;
152
+ if (sameTag.length > 1) {
153
+ segment += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
154
+ }
155
+ parts.unshift(segment);
156
+ cur = parent;
157
+ }
158
+ return parts.join(" > ");
159
+ }
160
+ function resolveSelector(selector) {
161
+ const root = defaultRoot();
162
+ if (!root || !selector) return null;
163
+ try {
164
+ return root.querySelector(selector);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ function snapshotElement(el) {
170
+ const r = el.getBoundingClientRect();
171
+ const idEl = el.closest("[data-fb]");
172
+ const elementId = idEl?.getAttribute("data-fb") ?? void 0;
173
+ const text = (el.textContent ?? "").trim().slice(0, 80);
174
+ return {
175
+ selector: selectorFor(el),
176
+ elementId,
177
+ tagName: el.tagName.toLowerCase(),
178
+ text: text || void 0,
179
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height }
180
+ };
181
+ }
182
+
183
+ // src/env.ts
184
+ function isBrowser() {
185
+ return typeof window !== "undefined" && typeof document !== "undefined";
186
+ }
187
+ function nowIso() {
188
+ return (/* @__PURE__ */ new Date()).toISOString();
189
+ }
190
+ function uuid() {
191
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
192
+ return crypto.randomUUID();
193
+ }
194
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
195
+ const r = Math.random() * 16 | 0;
196
+ const v = c === "x" ? r : r & 3 | 8;
197
+ return v.toString(16);
198
+ });
199
+ }
200
+
201
+ // src/version.ts
202
+ var SDK_VERSION = "0.0.0";
203
+
204
+ // src/event.ts
205
+ function normalizeTags(tags) {
206
+ const out = {};
207
+ for (const [k, v] of Object.entries(tags)) {
208
+ if (v === null || v === void 0) continue;
209
+ out[k] = v;
210
+ }
211
+ return out;
212
+ }
213
+ function buildEvent(client, input, scope) {
214
+ const options = client.getOptions();
215
+ let element;
216
+ if (input.element) {
217
+ element = snapshotElement(input.element);
218
+ } else if (input.elementId || input.elementInfo) {
219
+ element = {
220
+ selector: input.elementInfo?.selector ?? "",
221
+ elementId: input.elementId ?? input.elementInfo?.elementId,
222
+ tagName: input.elementInfo?.tagName,
223
+ text: input.elementInfo?.text,
224
+ rect: input.elementInfo?.rect
225
+ };
226
+ } else {
227
+ element = { selector: "" };
228
+ }
229
+ const event = {
230
+ eventId: uuid(),
231
+ timestamp: nowIso(),
232
+ release: options.release ?? "",
233
+ environment: options.environment ?? "production",
234
+ text: input.text,
235
+ element,
236
+ sdk: { name: "loupfeed", version: SDK_VERSION }
237
+ };
238
+ if (scope.user) event.user = scope.user;
239
+ const tags = normalizeTags(scope.tags);
240
+ if (Object.keys(tags).length) event.tags = tags;
241
+ if (Object.keys(scope.contexts).length) event.contexts = { ...scope.contexts };
242
+ if (scope.breadcrumbs.length) event.breadcrumbs = scope.breadcrumbs;
243
+ if (options.autoContext !== false && isBrowser()) {
244
+ event.request = { url: window.location.href };
245
+ const device = {
246
+ viewport: {
247
+ width: window.innerWidth,
248
+ height: window.innerHeight,
249
+ dpr: window.devicePixelRatio
250
+ }
251
+ };
252
+ event.contexts = { ...event.contexts ?? {}, device };
253
+ }
254
+ if (options.beforeSend) {
255
+ return options.beforeSend(event);
256
+ }
257
+ return event;
258
+ }
259
+
260
+ // src/scope.ts
261
+ var DEFAULT_MAX_BREADCRUMBS = 50;
262
+ var Scope = class _Scope {
263
+ constructor() {
264
+ this._tags = {};
265
+ this._contexts = {};
266
+ this._breadcrumbs = [];
267
+ this._maxBreadcrumbs = DEFAULT_MAX_BREADCRUMBS;
268
+ }
269
+ setUser(user) {
270
+ this._user = user ?? void 0;
271
+ return this;
272
+ }
273
+ getUser() {
274
+ return this._user;
275
+ }
276
+ setTag(key, value) {
277
+ this._tags[key] = value;
278
+ return this;
279
+ }
280
+ setTags(tags) {
281
+ Object.assign(this._tags, tags);
282
+ return this;
283
+ }
284
+ setContext(key, data) {
285
+ if (data === null) {
286
+ delete this._contexts[key];
287
+ } else {
288
+ this._contexts[key] = data;
289
+ }
290
+ return this;
291
+ }
292
+ addBreadcrumb(breadcrumb, maxBreadcrumbs) {
293
+ const crumb = { ...breadcrumb, timestamp: breadcrumb.timestamp ?? nowIso() };
294
+ this._breadcrumbs.push(crumb);
295
+ const limit = maxBreadcrumbs ?? this._maxBreadcrumbs;
296
+ if (this._breadcrumbs.length > limit) {
297
+ this._breadcrumbs.splice(0, this._breadcrumbs.length - limit);
298
+ }
299
+ return this;
300
+ }
301
+ setMaxBreadcrumbs(n) {
302
+ this._maxBreadcrumbs = n;
303
+ return this;
304
+ }
305
+ clearBreadcrumbs() {
306
+ this._breadcrumbs = [];
307
+ return this;
308
+ }
309
+ clone() {
310
+ const c = new _Scope();
311
+ c._user = this._user;
312
+ c._tags = { ...this._tags };
313
+ c._contexts = Object.fromEntries(
314
+ Object.entries(this._contexts).map(([k, v]) => [k, { ...v }])
315
+ );
316
+ c._breadcrumbs = [...this._breadcrumbs];
317
+ c._maxBreadcrumbs = this._maxBreadcrumbs;
318
+ return c;
319
+ }
320
+ getScopeData() {
321
+ return {
322
+ user: this._user,
323
+ tags: { ...this._tags },
324
+ contexts: Object.fromEntries(
325
+ Object.entries(this._contexts).map(([k, v]) => [k, { ...v }])
326
+ ),
327
+ breadcrumbs: [...this._breadcrumbs]
328
+ };
329
+ }
330
+ };
331
+ var globalScope;
332
+ var scopeStack = [];
333
+ function getGlobalScope() {
334
+ if (!globalScope) globalScope = new Scope();
335
+ return globalScope;
336
+ }
337
+ function getCurrentScope() {
338
+ return scopeStack[scopeStack.length - 1] ?? getGlobalScope();
339
+ }
340
+ function withScope(cb) {
341
+ const child = getCurrentScope().clone();
342
+ scopeStack.push(child);
343
+ try {
344
+ return cb(child);
345
+ } finally {
346
+ scopeStack.pop();
347
+ }
348
+ }
349
+ function setUser(user) {
350
+ getCurrentScope().setUser(user);
351
+ }
352
+ function setContext(key, data) {
353
+ getCurrentScope().setContext(key, data);
354
+ }
355
+ function setTag(key, value) {
356
+ getCurrentScope().setTag(key, value);
357
+ }
358
+ function setTags(tags) {
359
+ getCurrentScope().setTags(tags);
360
+ }
361
+ function addBreadcrumb(breadcrumb) {
362
+ getCurrentScope().addBreadcrumb(breadcrumb);
363
+ }
364
+
365
+ // src/replay.ts
366
+ var FULL_SNAPSHOT = 2;
367
+ var META = 4;
368
+ var stopFn = null;
369
+ var starting = false;
370
+ var buffer = [];
371
+ var checkpoints = [];
372
+ var replayOptions = {};
373
+ var bufferMs = 6e4;
374
+ var MAX_BYTES = 5e6;
375
+ function configureRecorder(opts) {
376
+ replayOptions = opts.replay ?? {};
377
+ if (typeof opts.replayBufferSeconds === "number") bufferMs = Math.max(5, opts.replayBufferSeconds) * 1e3;
378
+ }
379
+ function rrwebOptions(emit) {
380
+ const o = replayOptions;
381
+ return {
382
+ emit,
383
+ checkoutEveryNms: bufferMs,
384
+ // periodic full snapshots bound the buffer
385
+ // —— masking: privacy by DEFAULT (mask everything, opt out deliberately) ——
386
+ maskAllInputs: o.maskAllInputs !== false,
387
+ maskInputOptions: { password: true },
388
+ // passwords always, non-overridable
389
+ maskTextSelector: o.maskAllText !== false ? "*" : o.maskTextSelector,
390
+ blockSelector: o.blockSelector ?? "[data-fb-block],[data-private]",
391
+ recordCanvas: false,
392
+ collectFonts: false
393
+ };
394
+ }
395
+ function bytes() {
396
+ let n = 0;
397
+ for (const e of buffer) n += e.__sz ?? 0;
398
+ return n;
399
+ }
400
+ function enforce() {
401
+ while (checkpoints.length > 2) {
402
+ const dropBefore = checkpoints[1];
403
+ buffer = buffer.slice(dropBefore);
404
+ checkpoints = checkpoints.slice(1).map((i) => i - dropBefore);
405
+ }
406
+ while (checkpoints.length > 1 && bytes() > MAX_BYTES) {
407
+ const dropBefore = checkpoints[1];
408
+ buffer = buffer.slice(dropBefore);
409
+ checkpoints = checkpoints.slice(1).map((i) => i - dropBefore);
410
+ }
411
+ }
412
+ async function loadRrweb() {
413
+ try {
414
+ return await import('rrweb');
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+ async function startAsync() {
420
+ if (!isBrowser() || stopFn || starting) return;
421
+ starting = true;
422
+ if (document.readyState === "loading") {
423
+ await new Promise((r) => document.addEventListener("DOMContentLoaded", () => r(), { once: true }));
424
+ }
425
+ const rr = await loadRrweb();
426
+ const record = rr?.record;
427
+ if (typeof record !== "function") {
428
+ starting = false;
429
+ console.warn("[loupfeed] rrweb not available \u2014 session replay disabled. Install it: `npm i rrweb`.");
430
+ return;
431
+ }
432
+ buffer = [];
433
+ checkpoints = [];
434
+ const emit = (event, _isCheckout) => {
435
+ if (event.type === FULL_SNAPSHOT) {
436
+ const prev = buffer.length - 1;
437
+ const start = prev >= 0 && buffer[prev]?.type === META ? prev : buffer.length;
438
+ checkpoints.push(start);
439
+ }
440
+ try {
441
+ event.__sz = JSON.stringify(event).length;
442
+ } catch {
443
+ event.__sz = 256;
444
+ }
445
+ buffer.push(event);
446
+ enforce();
447
+ };
448
+ stopFn = record(rrwebOptions(emit)) ?? null;
449
+ starting = false;
450
+ }
451
+ var replay = {
452
+ start() {
453
+ void startAsync();
454
+ },
455
+ stop() {
456
+ stopFn?.();
457
+ stopFn = null;
458
+ buffer = [];
459
+ checkpoints = [];
460
+ },
461
+ isRecording() {
462
+ return stopFn != null || starting;
463
+ },
464
+ /** Freeze the rolling buffer for the current event. */
465
+ snapshot() {
466
+ if (buffer.length === 0 || checkpoints.length === 0) {
467
+ return { replayId: "", durationMs: 0, kind: "rrweb", events: [] };
468
+ }
469
+ const events = buffer.map((e) => {
470
+ const { __sz, ...rest } = e;
471
+ return rest;
472
+ });
473
+ const first = events[0];
474
+ const last = events[events.length - 1];
475
+ const durationMs = Math.max(0, (last?.timestamp ?? 0) - (first?.timestamp ?? 0));
476
+ return { replayId: uuid(), durationMs, kind: "rrweb", events };
477
+ }
478
+ };
479
+ var activeRecorder = replay;
480
+ function setReplayRecorder(recorder) {
481
+ activeRecorder = recorder ?? replay;
482
+ }
483
+ function getActiveRecorder() {
484
+ return activeRecorder;
485
+ }
486
+
487
+ // src/client.ts
488
+ var Client = class {
489
+ constructor(options) {
490
+ this.options = options;
491
+ this.dsn = parseDsn(options.dsn);
492
+ this.transport = options.transport ?? makeFetchTransport(this.dsn, { debug: options.debug });
493
+ if (typeof options.maxBreadcrumbs === "number") {
494
+ getGlobalScope().setMaxBreadcrumbs(options.maxBreadcrumbs);
495
+ }
496
+ }
497
+ getOptions() {
498
+ return this.options;
499
+ }
500
+ getDsn() {
501
+ return this.dsn;
502
+ }
503
+ getTransport() {
504
+ return this.transport;
505
+ }
506
+ async captureFeedback(input) {
507
+ const event = buildEvent(this, input, getCurrentScope().getScopeData());
508
+ if (!event) {
509
+ if (this.options.debug) console.warn("[loupfeed] event dropped by beforeSend");
510
+ return "";
511
+ }
512
+ await this.maybeAttachReplay(event);
513
+ if (this.options.debug) console.debug("[loupfeed] capture", event);
514
+ try {
515
+ return await this.transport.send(event);
516
+ } catch (err) {
517
+ console.error("[loupfeed] failed to send feedback", err);
518
+ return event.eventId;
519
+ }
520
+ }
521
+ async maybeAttachReplay(event) {
522
+ const rate = this.options.replaysSampleRate ?? 0;
523
+ const recorder = getActiveRecorder();
524
+ if (rate <= 0 || !this.transport.sendReplay || !recorder.isRecording()) return;
525
+ if (Math.random() > rate) return;
526
+ try {
527
+ const buffer2 = await recorder.snapshot();
528
+ if (!buffer2 || !buffer2.events?.length && !buffer2.frames?.length && !buffer2.video) return;
529
+ const { replayId, durationMs } = await this.transport.sendReplay(buffer2);
530
+ if (replayId) event.replay = { replayId, durationMs };
531
+ } catch (err) {
532
+ if (this.options.debug) console.warn("[loupfeed] replay upload failed", err);
533
+ }
534
+ }
535
+ async flush(timeout) {
536
+ return this.transport.flush(timeout);
537
+ }
538
+ async close(timeout) {
539
+ const res = await this.transport.flush(timeout);
540
+ this.transport.close?.();
541
+ return res;
542
+ }
543
+ };
544
+
545
+ // src/sdk.ts
546
+ var currentClient;
547
+ function init(options) {
548
+ const client = new Client(options);
549
+ currentClient = client;
550
+ const replayEnabled = (options.replaysSampleRate ?? 0) > 0 || options.replay != null;
551
+ if (replayEnabled) {
552
+ configureRecorder({ replay: options.replay, replayBufferSeconds: options.replayBufferSeconds });
553
+ replay.start();
554
+ }
555
+ if (options.debug) {
556
+ console.debug("[loupfeed] initialized", {
557
+ org: client.dsn.orgId,
558
+ project: client.dsn.projectId,
559
+ host: client.dsn.host,
560
+ release: options.release,
561
+ environment: options.environment
562
+ });
563
+ }
564
+ return client;
565
+ }
566
+ function getClient() {
567
+ return currentClient;
568
+ }
569
+ async function captureFeedback(input) {
570
+ const client = currentClient;
571
+ if (!client) {
572
+ console.warn("[loupfeed] captureFeedback() called before init(); ignoring.");
573
+ return "";
574
+ }
575
+ return client.captureFeedback(input);
576
+ }
577
+ async function close(timeout) {
578
+ if (!currentClient) return true;
579
+ const res = await currentClient.close(timeout);
580
+ currentClient = void 0;
581
+ return res;
582
+ }
583
+
584
+ // src/overlay/styles.ts
585
+ function overlayCss(accent) {
586
+ return `
587
+ :host { all: initial; }
588
+ * { box-sizing: border-box; }
589
+
590
+ .lf-highlight {
591
+ position: fixed;
592
+ pointer-events: none;
593
+ border: 2px solid ${accent};
594
+ border-radius: 3px;
595
+ background: ${accent}1f;
596
+ box-shadow: 0 0 0 1px rgba(255,255,255,.45);
597
+ z-index: 1;
598
+ transition: left .05s, top .05s, width .05s, height .05s;
599
+ display: none;
600
+ }
601
+ .lf-label {
602
+ position: absolute;
603
+ top: -22px;
604
+ left: -2px;
605
+ font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
606
+ color: #fff;
607
+ background: ${accent};
608
+ padding: 1px 6px;
609
+ border-radius: 3px;
610
+ white-space: nowrap;
611
+ pointer-events: none;
612
+ }
613
+
614
+ .lf-launcher {
615
+ position: fixed;
616
+ right: 16px;
617
+ bottom: 16px;
618
+ pointer-events: auto;
619
+ font: 600 13px/1 ui-sans-serif, system-ui, -apple-system, sans-serif;
620
+ color: #fff;
621
+ background: ${accent};
622
+ border: none;
623
+ border-radius: 999px;
624
+ padding: 10px 16px;
625
+ cursor: pointer;
626
+ box-shadow: 0 4px 14px rgba(0,0,0,.25);
627
+ }
628
+ .lf-launcher[data-active="true"] { background: #111; }
629
+ .lf-launcher:hover { filter: brightness(1.06); }
630
+
631
+ .lf-popup {
632
+ position: fixed;
633
+ pointer-events: auto;
634
+ width: 280px;
635
+ background: #fff;
636
+ color: #111;
637
+ border-radius: 10px;
638
+ box-shadow: 0 10px 40px rgba(0,0,0,.3);
639
+ padding: 12px;
640
+ font: 400 13px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;
641
+ display: none;
642
+ z-index: 2;
643
+ }
644
+ .lf-popup[data-open="true"] { display: block; }
645
+ .lf-target {
646
+ font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
647
+ color: ${accent};
648
+ margin-bottom: 6px;
649
+ word-break: break-all;
650
+ }
651
+ .lf-popup textarea {
652
+ width: 100%;
653
+ min-height: 64px;
654
+ resize: vertical;
655
+ border: 1px solid #dcdce3;
656
+ border-radius: 6px;
657
+ padding: 8px;
658
+ font: inherit;
659
+ outline: none;
660
+ }
661
+ .lf-popup textarea:focus { border-color: ${accent}; }
662
+ .lf-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
663
+ .lf-btn {
664
+ font: 600 12px/1 ui-sans-serif, system-ui, -apple-system, sans-serif;
665
+ border-radius: 6px;
666
+ padding: 7px 12px;
667
+ cursor: pointer;
668
+ border: 1px solid transparent;
669
+ }
670
+ .lf-btn-primary { background: ${accent}; color: #fff; }
671
+ .lf-btn-primary:disabled { opacity: .6; cursor: default; }
672
+ .lf-btn-ghost { background: transparent; color: #555; border-color: #dcdce3; }
673
+ .lf-sent { color: #16a34a; font-weight: 600; padding: 6px 2px; }
674
+ `;
675
+ }
676
+
677
+ // src/overlay/index.ts
678
+ function noopController() {
679
+ return {
680
+ open() {
681
+ },
682
+ close() {
683
+ },
684
+ toggleInspect() {
685
+ },
686
+ isInspecting() {
687
+ return false;
688
+ },
689
+ destroy() {
690
+ }
691
+ };
692
+ }
693
+ function createOverlay(opts = {}) {
694
+ if (!isBrowser()) return noopController();
695
+ const accent = opts.accentColor ?? "#6d28d9";
696
+ const zIndex = opts.zIndex ?? 2147483600;
697
+ const root = opts.root ?? document.body;
698
+ const submit = opts.onSubmit ?? ((input) => void captureFeedback(input));
699
+ const showLauncher = opts.launcher !== false;
700
+ setSelectorRoot(root);
701
+ const host = document.createElement("div");
702
+ host.setAttribute("data-loupfeed", "overlay");
703
+ host.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${zIndex};`;
704
+ const shadow = host.attachShadow({ mode: "open" });
705
+ const style = document.createElement("style");
706
+ style.textContent = overlayCss(accent);
707
+ shadow.append(style);
708
+ const highlight = document.createElement("div");
709
+ highlight.className = "lf-highlight";
710
+ const label = document.createElement("div");
711
+ label.className = "lf-label";
712
+ highlight.append(label);
713
+ shadow.append(highlight);
714
+ const popup = document.createElement("div");
715
+ popup.className = "lf-popup";
716
+ shadow.append(popup);
717
+ let launcher = null;
718
+ if (showLauncher) {
719
+ launcher = document.createElement("button");
720
+ launcher.type = "button";
721
+ launcher.className = "lf-launcher";
722
+ launcher.textContent = "Feedback";
723
+ launcher.addEventListener("click", () => toggleInspect());
724
+ shadow.append(launcher);
725
+ }
726
+ document.body.append(host);
727
+ let state = "idle";
728
+ let current = null;
729
+ function describe(elm) {
730
+ const tag = elm.tagName.toLowerCase();
731
+ const id = elm.closest("[data-fb]")?.getAttribute("data-fb");
732
+ return id ? `${tag} \xB7 ${id}` : tag;
733
+ }
734
+ function setHighlight(elm) {
735
+ if (!elm) {
736
+ highlight.style.display = "none";
737
+ return;
738
+ }
739
+ const r = elm.getBoundingClientRect();
740
+ highlight.style.display = "block";
741
+ highlight.style.left = `${r.left}px`;
742
+ highlight.style.top = `${r.top}px`;
743
+ highlight.style.width = `${r.width}px`;
744
+ highlight.style.height = `${r.height}px`;
745
+ label.textContent = describe(elm);
746
+ }
747
+ function onMove(e) {
748
+ if (state !== "inspect") return;
749
+ const el = document.elementFromPoint(e.clientX, e.clientY);
750
+ if (!el || el === host) {
751
+ current = null;
752
+ setHighlight(null);
753
+ return;
754
+ }
755
+ current = el;
756
+ setHighlight(el);
757
+ }
758
+ function onClick(e) {
759
+ if (state !== "inspect") return;
760
+ if (e.target === host) return;
761
+ e.preventDefault();
762
+ e.stopPropagation();
763
+ const target = current ?? document.elementFromPoint(e.clientX, e.clientY);
764
+ if (!target || target === host) return;
765
+ openCompose(target);
766
+ }
767
+ function onKey(e) {
768
+ if (e.key !== "Escape") return;
769
+ if (state === "compose") {
770
+ closeCompose();
771
+ enterInspect();
772
+ } else if (state === "inspect") {
773
+ exitInspect();
774
+ }
775
+ }
776
+ function enterInspect() {
777
+ state = "inspect";
778
+ if (launcher) launcher.dataset.active = "true";
779
+ document.addEventListener("mousemove", onMove, true);
780
+ document.addEventListener("click", onClick, true);
781
+ document.addEventListener("keydown", onKey, true);
782
+ }
783
+ function exitInspect() {
784
+ state = "idle";
785
+ current = null;
786
+ setHighlight(null);
787
+ if (launcher) launcher.dataset.active = "false";
788
+ document.removeEventListener("mousemove", onMove, true);
789
+ document.removeEventListener("click", onClick, true);
790
+ document.removeEventListener("keydown", onKey, true);
791
+ }
792
+ function button(text, cls) {
793
+ const b = document.createElement("button");
794
+ b.type = "button";
795
+ b.className = cls;
796
+ b.textContent = text;
797
+ return b;
798
+ }
799
+ function positionPopup(r) {
800
+ const margin = 8;
801
+ const w = 280;
802
+ let left = r.left;
803
+ let top = r.bottom + margin;
804
+ if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
805
+ if (left < margin) left = margin;
806
+ if (top + 168 > window.innerHeight) top = Math.max(margin, r.top - 168);
807
+ popup.style.left = `${left}px`;
808
+ popup.style.top = `${top}px`;
809
+ }
810
+ function openCompose(target) {
811
+ state = "compose";
812
+ current = target;
813
+ setHighlight(target);
814
+ document.removeEventListener("mousemove", onMove, true);
815
+ document.removeEventListener("click", onClick, true);
816
+ const info = snapshotElement(target);
817
+ popup.innerHTML = "";
818
+ const targetLine = document.createElement("div");
819
+ targetLine.className = "lf-target";
820
+ targetLine.textContent = info.elementId ? `${info.tagName} \xB7 ${info.elementId}` : info.tagName ?? "element";
821
+ const textarea = document.createElement("textarea");
822
+ textarea.placeholder = "What's wrong with this element?";
823
+ const row = document.createElement("div");
824
+ row.className = "lf-row";
825
+ const cancel = button("Cancel", "lf-btn lf-btn-ghost");
826
+ const send = button("Send", "lf-btn lf-btn-primary");
827
+ row.append(cancel, send);
828
+ popup.append(targetLine, textarea, row);
829
+ positionPopup(target.getBoundingClientRect());
830
+ popup.dataset.open = "true";
831
+ setTimeout(() => textarea.focus(), 0);
832
+ cancel.addEventListener("click", () => {
833
+ closeCompose();
834
+ enterInspect();
835
+ });
836
+ send.addEventListener("click", () => {
837
+ void submitCompose(target, textarea, send);
838
+ });
839
+ }
840
+ async function submitCompose(target, textarea, send) {
841
+ const text = textarea.value.trim();
842
+ if (!text) {
843
+ textarea.focus();
844
+ return;
845
+ }
846
+ send.disabled = true;
847
+ try {
848
+ await submit({ text, element: target });
849
+ } catch {
850
+ }
851
+ popup.innerHTML = '<div class="lf-sent">Sent \u2713</div>';
852
+ setTimeout(() => {
853
+ closeCompose();
854
+ exitInspect();
855
+ }, 900);
856
+ }
857
+ function closeCompose() {
858
+ popup.dataset.open = "false";
859
+ popup.innerHTML = "";
860
+ if (state === "compose") state = "idle";
861
+ setHighlight(null);
862
+ }
863
+ function toggleInspect() {
864
+ if (state === "idle") enterInspect();
865
+ else {
866
+ closeCompose();
867
+ exitInspect();
868
+ }
869
+ }
870
+ function open() {
871
+ if (state === "idle") enterInspect();
872
+ }
873
+ function close2() {
874
+ closeCompose();
875
+ exitInspect();
876
+ }
877
+ function isInspecting() {
878
+ return state !== "idle";
879
+ }
880
+ function destroy() {
881
+ close2();
882
+ host.remove();
883
+ }
884
+ return { open, close: close2, toggleInspect, isInspecting, destroy };
885
+ }
886
+ var active = null;
887
+ function mountOverlay(opts) {
888
+ const controller = createOverlay(opts);
889
+ active = controller;
890
+ return () => {
891
+ controller.destroy();
892
+ if (active === controller) active = null;
893
+ };
894
+ }
895
+ var overlay = {
896
+ open() {
897
+ active?.open();
898
+ },
899
+ close() {
900
+ active?.close();
901
+ },
902
+ toggleInspect() {
903
+ active?.toggleInspect();
904
+ },
905
+ isInspecting() {
906
+ return active?.isInspecting() ?? false;
907
+ }
908
+ };
909
+
910
+ exports.Client = Client;
911
+ exports.SDK_VERSION = SDK_VERSION;
912
+ exports.Scope = Scope;
913
+ exports.addBreadcrumb = addBreadcrumb;
914
+ exports.captureFeedback = captureFeedback;
915
+ exports.close = close;
916
+ exports.eventsUrl = eventsUrl;
917
+ exports.getActiveRecorder = getActiveRecorder;
918
+ exports.getClient = getClient;
919
+ exports.getCurrentScope = getCurrentScope;
920
+ exports.getGlobalScope = getGlobalScope;
921
+ exports.getSelectorRoot = getSelectorRoot;
922
+ exports.init = init;
923
+ exports.makeFetchTransport = makeFetchTransport;
924
+ exports.manifestsUrl = manifestsUrl;
925
+ exports.mountOverlay = mountOverlay;
926
+ exports.overlay = overlay;
927
+ exports.parseDsn = parseDsn;
928
+ exports.replay = replay;
929
+ exports.replaysUrl = replaysUrl;
930
+ exports.resolveSelector = resolveSelector;
931
+ exports.resolveUrl = resolveUrl;
932
+ exports.selectorFor = selectorFor;
933
+ exports.setContext = setContext;
934
+ exports.setReplayRecorder = setReplayRecorder;
935
+ exports.setSelectorRoot = setSelectorRoot;
936
+ exports.setTag = setTag;
937
+ exports.setTags = setTags;
938
+ exports.setUser = setUser;
939
+ exports.snapshotElement = snapshotElement;
940
+ exports.withScope = withScope;
941
+ //# sourceMappingURL=index.cjs.map
942
+ //# sourceMappingURL=index.cjs.map