feedtack 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1308 @@
1
+ // src/capture/content.ts
2
+ var DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
3
+ function scanFields(root) {
4
+ const searchRoot = root ?? document.body;
5
+ const nodes = Array.from(
6
+ searchRoot.querySelectorAll("[data-feedtack-field]")
7
+ );
8
+ const seen = /* @__PURE__ */ new Map();
9
+ const fields = [];
10
+ for (const el of nodes) {
11
+ const fieldPath = el.dataset.feedtackField ?? "";
12
+ if (!fieldPath) continue;
13
+ seen.set(fieldPath, (seen.get(fieldPath) ?? 0) + 1);
14
+ fields.push({
15
+ fieldPath,
16
+ element: el,
17
+ content: el.textContent ?? ""
18
+ });
19
+ }
20
+ if (DEV) {
21
+ for (const [path, count] of seen) {
22
+ if (count > 1) {
23
+ console.warn(
24
+ `[feedtack] Duplicate data-feedtack-field="${path}" found ${count} times on this page. Field paths must be unique.`
25
+ );
26
+ }
27
+ }
28
+ }
29
+ return fields;
30
+ }
31
+ async function hashField(content) {
32
+ const encoded = new TextEncoder().encode(content);
33
+ const buffer = await crypto.subtle.digest("SHA-256", encoded);
34
+ const hex = Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
35
+ return hex.slice(0, 12);
36
+ }
37
+
38
+ // src/capture/meta.ts
39
+ function getViewportMeta() {
40
+ return {
41
+ width: window.innerWidth,
42
+ height: window.innerHeight,
43
+ scrollX: window.scrollX,
44
+ scrollY: window.scrollY,
45
+ devicePixelRatio: window.devicePixelRatio
46
+ };
47
+ }
48
+ function getPageMeta() {
49
+ return {
50
+ url: window.location.href,
51
+ pathname: window.location.pathname,
52
+ title: document.title
53
+ };
54
+ }
55
+ function getDeviceMeta() {
56
+ return {
57
+ userAgent: navigator.userAgent,
58
+ platform: navigator.platform,
59
+ touchEnabled: navigator.maxTouchPoints > 0
60
+ };
61
+ }
62
+ function getPinCoords(event) {
63
+ const x = event.clientX + window.scrollX;
64
+ const y = event.clientY + window.scrollY;
65
+ const docWidth = document.documentElement.scrollWidth;
66
+ const docHeight = document.documentElement.scrollHeight;
67
+ return {
68
+ x,
69
+ y,
70
+ xPct: Number((x / docWidth * 100).toFixed(2)),
71
+ yPct: Number((y / docHeight * 100).toFixed(2))
72
+ };
73
+ }
74
+
75
+ // src/capture/fiber.ts
76
+ var fiberKey;
77
+ function getFiberKey(element) {
78
+ if (fiberKey !== void 0) return fiberKey;
79
+ const key = Object.keys(element).find((k) => k.startsWith("__reactFiber$"));
80
+ fiberKey = key ?? null;
81
+ return fiberKey;
82
+ }
83
+ function getComponentName(element) {
84
+ try {
85
+ const key = getFiberKey(element);
86
+ if (!key) return null;
87
+ let fiber = element[key];
88
+ while (fiber) {
89
+ const type = fiber.type;
90
+ if (type && typeof type !== "string") {
91
+ const name = type.displayName ?? type.name;
92
+ if (name && name !== "Anonymous") return name;
93
+ }
94
+ fiber = fiber.return;
95
+ }
96
+ return null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // src/capture/target.ts
103
+ var INTERACTIVE_SELECTOR = "button,a,input,select,textarea,label";
104
+ function resolveTarget(element) {
105
+ const promoted = element.closest(INTERACTIVE_SELECTOR);
106
+ return promoted ?? element;
107
+ }
108
+ function attr(el, name) {
109
+ return el.getAttribute(name);
110
+ }
111
+ function nthChild(el) {
112
+ let n = 1;
113
+ let sib = el.previousElementSibling;
114
+ while (sib) {
115
+ n++;
116
+ sib = sib.previousElementSibling;
117
+ }
118
+ return n;
119
+ }
120
+ function nthOfType(el) {
121
+ const tag = el.tagName;
122
+ let n = 1;
123
+ let sib = el.previousElementSibling;
124
+ while (sib) {
125
+ if (sib.tagName === tag) n++;
126
+ sib = sib.previousElementSibling;
127
+ }
128
+ return n;
129
+ }
130
+ function serializeNode(el) {
131
+ const id = attr(el, "id");
132
+ const dataTestId = attr(el, "data-testid") ?? attr(el, "data-test-id");
133
+ const dataFeedtackComponent = attr(el, "data-feedtack-component");
134
+ const hasStableId = !!(id || dataTestId);
135
+ return {
136
+ tag: el.tagName.toLowerCase(),
137
+ id,
138
+ ariaLabel: attr(el, "aria-label"),
139
+ role: attr(el, "role"),
140
+ type: attr(el, "type"),
141
+ name: attr(el, "name"),
142
+ title: attr(el, "title"),
143
+ alt: attr(el, "alt"),
144
+ dataTestId,
145
+ dataFeedtackComponent,
146
+ nthChild: hasStableId ? null : nthChild(el),
147
+ nthOfType: hasStableId ? null : nthOfType(el),
148
+ componentName: dataFeedtackComponent ?? getComponentName(el)
149
+ };
150
+ }
151
+ function getAncestorChain(element) {
152
+ const chain = [];
153
+ let current = element.parentElement;
154
+ while (current && current !== document.body && chain.length < 5) {
155
+ chain.push(serializeNode(current));
156
+ current = current.parentElement;
157
+ }
158
+ return chain;
159
+ }
160
+ function getCSSSelector(element) {
161
+ const parts = [];
162
+ let current = element;
163
+ while (current && current !== document.body) {
164
+ const id = current.getAttribute("id");
165
+ const testId = current.getAttribute("data-testid") ?? current.getAttribute("data-test-id");
166
+ const feedtackComponent = current.getAttribute("data-feedtack-component");
167
+ if (id) {
168
+ parts.unshift(`#${id}`);
169
+ break;
170
+ } else if (testId) {
171
+ parts.unshift(`[data-testid="${testId}"]`);
172
+ break;
173
+ } else if (feedtackComponent) {
174
+ parts.unshift(`[data-feedtack-component="${feedtackComponent}"]`);
175
+ break;
176
+ } else {
177
+ const tag = current.tagName.toLowerCase();
178
+ const parent = current.parentElement;
179
+ if (parent) {
180
+ const siblings = Array.from(parent.children).filter(
181
+ (c) => c.tagName === current.tagName
182
+ );
183
+ const index = siblings.indexOf(current) + 1;
184
+ parts.unshift(
185
+ siblings.length > 1 ? `${tag}:nth-of-type(${index})` : tag
186
+ );
187
+ } else {
188
+ parts.unshift(tag);
189
+ }
190
+ }
191
+ current = current.parentElement;
192
+ }
193
+ return parts.join(" > ");
194
+ }
195
+ function deriveElementPath(target, ancestors) {
196
+ const dataTestId = target.getAttribute("data-testid") ?? target.getAttribute("data-test-id");
197
+ if (dataTestId) return null;
198
+ const targetPart = (() => {
199
+ const tag = target.tagName.toLowerCase();
200
+ const classes = Array.from(target.classList).join(".");
201
+ return classes ? `${tag}.${classes}` : tag;
202
+ })();
203
+ const ancestorParts = ancestors.map((a) => {
204
+ if (a.dataTestId) return `[data-testid="${a.dataTestId}"]`;
205
+ const classes = "";
206
+ return classes ? `${a.tag}.${classes}` : a.tag;
207
+ });
208
+ return [targetPart, ...ancestorParts].join(" > ");
209
+ }
210
+ function getTargetMeta(element) {
211
+ const resolved = resolveTarget(element);
212
+ const id = resolved.getAttribute("id");
213
+ const dataTestId = resolved.getAttribute("data-testid") ?? resolved.getAttribute("data-test-id");
214
+ const feedtackComponent = resolved.getAttribute("data-feedtack-component");
215
+ let selector;
216
+ let best_effort;
217
+ if (id) {
218
+ selector = `#${id}`;
219
+ best_effort = false;
220
+ } else if (dataTestId) {
221
+ selector = `[data-testid="${dataTestId}"]`;
222
+ best_effort = false;
223
+ } else if (feedtackComponent) {
224
+ selector = `[data-feedtack-component="${feedtackComponent}"]`;
225
+ best_effort = false;
226
+ } else {
227
+ selector = getCSSSelector(resolved);
228
+ best_effort = true;
229
+ }
230
+ const ancestors = getAncestorChain(resolved);
231
+ const rect = resolved.getBoundingClientRect();
232
+ return {
233
+ selector,
234
+ best_effort,
235
+ dataTestId,
236
+ elementPath: deriveElementPath(resolved, ancestors),
237
+ tagName: resolved.tagName,
238
+ ancestors,
239
+ boundingRect: {
240
+ x: rect.x + window.scrollX,
241
+ y: rect.y + window.scrollY,
242
+ width: rect.width,
243
+ height: rect.height
244
+ }
245
+ };
246
+ }
247
+
248
+ // src/ui/colors.ts
249
+ var PIN_PALETTE = [
250
+ "#ef4444",
251
+ // red
252
+ "#3b82f6",
253
+ // blue
254
+ "#22c55e",
255
+ // green
256
+ "#f59e0b",
257
+ // amber
258
+ "#a855f7",
259
+ // purple
260
+ "#ec4899"
261
+ // pink
262
+ ];
263
+
264
+ // src/types/payload.ts
265
+ var SCHEMA_VERSION = "2.0.0";
266
+
267
+ // src/core/types.ts
268
+ function generateId() {
269
+ return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
270
+ }
271
+
272
+ // src/types/theme.ts
273
+ function themeToCSS(theme) {
274
+ const map = {};
275
+ if (theme.primary) map["--ft-primary"] = theme.primary;
276
+ if (theme.background) map["--ft-bg"] = theme.background;
277
+ if (theme.surface) map["--ft-surface"] = theme.surface;
278
+ if (theme.text) map["--ft-text"] = theme.text;
279
+ if (theme.textMuted) map["--ft-text-muted"] = theme.textMuted;
280
+ if (theme.border) map["--ft-border"] = theme.border;
281
+ if (theme.radius) map["--ft-radius"] = theme.radius;
282
+ if (theme.badge) map["--ft-badge"] = theme.badge;
283
+ return map;
284
+ }
285
+
286
+ // src/core/actions.ts
287
+ function updateItem(ctx, id, fn) {
288
+ ctx.setState({
289
+ feedbackItems: ctx.getState().feedbackItems.map((i) => i.payload.id === id ? fn(i) : i)
290
+ });
291
+ }
292
+ async function handleSubmit(ctx) {
293
+ const s = ctx.getState();
294
+ if (!s.comment.trim()) {
295
+ ctx.setState({ commentError: true });
296
+ return;
297
+ }
298
+ ctx.setState({ submitting: true });
299
+ const payload = {
300
+ schemaVersion: SCHEMA_VERSION,
301
+ id: generateId(),
302
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
303
+ scope: ctx.getCurrentScope(),
304
+ submittedBy: ctx.currentUser,
305
+ comment: s.comment.trim(),
306
+ sentiment: s.sentiment,
307
+ pins: s.pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
308
+ page: getPageMeta(),
309
+ viewport: getViewportMeta(),
310
+ device: getDeviceMeta()
311
+ };
312
+ try {
313
+ await ctx.adapter.submit(payload);
314
+ ctx.setState({
315
+ feedbackItems: [
316
+ ...ctx.getState().feedbackItems,
317
+ { payload, replies: [], resolutions: [], archives: [] }
318
+ ]
319
+ });
320
+ ctx.deactivatePinMode();
321
+ } catch (err) {
322
+ ctx.onError?.(err);
323
+ } finally {
324
+ ctx.setState({ submitting: false });
325
+ }
326
+ }
327
+ async function handleModalSubmit(ctx) {
328
+ if (!ctx.getState().comment.trim()) {
329
+ ctx.setState({ commentError: true });
330
+ return;
331
+ }
332
+ const scope = ctx.getState().composeScope;
333
+ await handleSubmit(ctx);
334
+ const items = ctx.getState().feedbackItems;
335
+ const newItem = items[items.length - 1];
336
+ if (newItem && newItem.payload.scope === scope) {
337
+ const key = scope === "site" ? "siteFeedback" : "pageFeedback";
338
+ ctx.setState({
339
+ [key]: [...ctx.getState()[key], newItem],
340
+ feedbackItems: items.slice(0, -1)
341
+ });
342
+ }
343
+ ctx.setState({ comment: "", sentiment: null, commentError: false });
344
+ }
345
+ async function handleReply(ctx, feedbackId) {
346
+ const body = ctx.getState().replyBody.trim();
347
+ if (!body) return;
348
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
349
+ try {
350
+ await ctx.adapter.reply(feedbackId, {
351
+ author: ctx.currentUser,
352
+ body,
353
+ timestamp: ts
354
+ });
355
+ updateItem(ctx, feedbackId, (item) => {
356
+ const updated = {
357
+ ...item,
358
+ replies: [
359
+ ...item.replies,
360
+ {
361
+ id: generateId(),
362
+ feedbackId,
363
+ author: ctx.currentUser,
364
+ body,
365
+ timestamp: ts
366
+ }
367
+ ]
368
+ };
369
+ const rescope = ctx.rescopeRoles ? ctx.rescopeRoles.includes(ctx.currentUser.role) : ctx.currentUser.role !== "agent";
370
+ if (rescope && updated.resolutions.length === 0 && ctx.flushCtrl) {
371
+ ctx.flushCtrl.clearFlushed(ctx.getState().pathname);
372
+ }
373
+ return updated;
374
+ });
375
+ ctx.setState({ replyBody: "" });
376
+ } catch (err) {
377
+ ctx.onError?.(err);
378
+ }
379
+ }
380
+ async function handleResolve(ctx, feedbackId) {
381
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
382
+ try {
383
+ await ctx.adapter.resolve(feedbackId, {
384
+ resolvedBy: ctx.currentUser,
385
+ timestamp: ts
386
+ });
387
+ updateItem(ctx, feedbackId, (item) => ({
388
+ ...item,
389
+ resolutions: [
390
+ ...item.resolutions,
391
+ { feedbackId, resolvedBy: ctx.currentUser, timestamp: ts }
392
+ ]
393
+ }));
394
+ } catch (err) {
395
+ ctx.onError?.(err);
396
+ }
397
+ }
398
+ async function handleArchive(ctx, feedbackId) {
399
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
400
+ try {
401
+ await ctx.adapter.archive(feedbackId, ctx.currentUser.id);
402
+ updateItem(ctx, feedbackId, (item) => ({
403
+ ...item,
404
+ archives: [
405
+ ...item.archives,
406
+ { feedbackId, archivedBy: ctx.currentUser, timestamp: ts }
407
+ ]
408
+ }));
409
+ ctx.setState({ openThreadId: null });
410
+ } catch (err) {
411
+ ctx.onError?.(err);
412
+ }
413
+ }
414
+ function loadFeedback(ctx) {
415
+ ctx.setState({ loading: true });
416
+ ctx.adapter.loadFeedback({ pathname: ctx.getState().pathname }).then((items) => {
417
+ const feedbackItems = [];
418
+ const siteFeedback = [];
419
+ const pageFeedback = [];
420
+ for (const item of items) {
421
+ if (item.payload.scope === "site") siteFeedback.push(item);
422
+ else if (item.payload.scope === "page") pageFeedback.push(item);
423
+ else feedbackItems.push(item);
424
+ }
425
+ ctx.setState({ feedbackItems, siteFeedback, pageFeedback });
426
+ }).catch((err) => ctx.onError?.(err)).finally(() => ctx.setState({ loading: false }));
427
+ }
428
+
429
+ // src/ui/modalStyles.ts
430
+ var FEEDTACK_MODAL_STYLES = `
431
+ .feedtack-loading {
432
+ position: fixed;
433
+ bottom: 70px;
434
+ right: 24px;
435
+ font-size: 12px;
436
+ color: var(--ft-text-muted);
437
+ z-index: 2147483640;
438
+ }
439
+
440
+ .feedtack-modal {
441
+ position: fixed;
442
+ bottom: 72px;
443
+ right: 24px;
444
+ width: 360px;
445
+ max-height: 70vh;
446
+ margin: 0;
447
+ padding: 0;
448
+ background: var(--ft-bg);
449
+ border: 1px solid var(--ft-border);
450
+ border-radius: calc(var(--ft-radius) + 4px);
451
+ box-shadow: 0 8px 32px rgba(0,0,0,0.18);
452
+ z-index: 2147483643;
453
+ display: flex;
454
+ flex-direction: column;
455
+ overflow: hidden;
456
+ }
457
+
458
+ .feedtack-modal::backdrop {
459
+ background: transparent;
460
+ }
461
+
462
+ .feedtack-modal-header {
463
+ display: flex;
464
+ align-items: center;
465
+ justify-content: space-between;
466
+ padding: 14px 16px 0;
467
+ }
468
+
469
+ .feedtack-modal-title {
470
+ font-size: 15px;
471
+ font-weight: 600;
472
+ color: var(--ft-text);
473
+ }
474
+
475
+ .feedtack-modal-close {
476
+ background: none;
477
+ border: none;
478
+ font-size: 20px;
479
+ cursor: pointer;
480
+ color: var(--ft-text-muted);
481
+ line-height: 1;
482
+ padding: 0 4px;
483
+ }
484
+
485
+ .feedtack-modal-tabs {
486
+ display: flex;
487
+ gap: 0;
488
+ padding: 12px 16px 0;
489
+ border-bottom: 1px solid var(--ft-border);
490
+ }
491
+
492
+ .feedtack-modal-tab {
493
+ flex: 1;
494
+ padding: 8px 12px;
495
+ border: none;
496
+ background: none;
497
+ font-size: 13px;
498
+ font-weight: 500;
499
+ cursor: pointer;
500
+ color: var(--ft-text-muted);
501
+ border-bottom: 2px solid transparent;
502
+ margin-bottom: -1px;
503
+ display: flex;
504
+ align-items: center;
505
+ justify-content: center;
506
+ gap: 6px;
507
+ }
508
+
509
+ .feedtack-modal-tab.active {
510
+ color: var(--ft-primary);
511
+ border-bottom-color: var(--ft-primary);
512
+ }
513
+
514
+ .feedtack-tab-count {
515
+ font-size: 11px;
516
+ background: var(--ft-surface);
517
+ color: var(--ft-text-muted);
518
+ padding: 1px 6px;
519
+ border-radius: 10px;
520
+ }
521
+
522
+ .feedtack-modal-body {
523
+ flex: 1;
524
+ overflow-y: auto;
525
+ padding: 12px 16px;
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: 12px;
529
+ }
530
+
531
+ .feedtack-modal-threads {
532
+ display: flex;
533
+ flex-direction: column;
534
+ gap: 6px;
535
+ }
536
+
537
+ .feedtack-modal-thread-item {
538
+ display: flex;
539
+ flex-direction: column;
540
+ gap: 2px;
541
+ text-align: left;
542
+ padding: 10px 12px;
543
+ background: var(--ft-surface);
544
+ border: 1px solid var(--ft-border);
545
+ border-radius: var(--ft-radius);
546
+ cursor: pointer;
547
+ }
548
+
549
+ .feedtack-modal-thread-item:hover {
550
+ border-color: var(--ft-primary);
551
+ }
552
+
553
+ .feedtack-thread-author {
554
+ font-size: 12px;
555
+ font-weight: 600;
556
+ color: var(--ft-text);
557
+ }
558
+
559
+ .feedtack-thread-comment {
560
+ font-size: 13px;
561
+ color: var(--ft-text);
562
+ overflow: hidden;
563
+ text-overflow: ellipsis;
564
+ white-space: nowrap;
565
+ }
566
+
567
+ .feedtack-thread-meta {
568
+ font-size: 11px;
569
+ color: var(--ft-text-muted);
570
+ }
571
+
572
+ .feedtack-modal-compose {
573
+ display: flex;
574
+ flex-direction: column;
575
+ gap: 8px;
576
+ }
577
+
578
+ .feedtack-modal-textarea {
579
+ width: 100%;
580
+ border: 1.5px solid var(--ft-border);
581
+ border-radius: var(--ft-radius);
582
+ padding: 8px;
583
+ font-size: 13px;
584
+ resize: vertical;
585
+ min-height: 72px;
586
+ outline: none;
587
+ background: var(--ft-surface);
588
+ color: var(--ft-text);
589
+ }
590
+
591
+ .feedtack-modal-textarea:focus {
592
+ border-color: var(--ft-primary);
593
+ }
594
+
595
+ .feedtack-modal-textarea.error {
596
+ border-color: var(--ft-error);
597
+ }
598
+
599
+ .feedtack-modal-footer {
600
+ padding: 10px 16px 14px;
601
+ border-top: 1px solid var(--ft-border);
602
+ }
603
+
604
+ .feedtack-modal-pin-btn {
605
+ width: 100%;
606
+ padding: 8px 14px;
607
+ border: 1.5px solid var(--ft-border);
608
+ border-radius: var(--ft-radius);
609
+ background: var(--ft-bg);
610
+ color: var(--ft-text);
611
+ font-size: 13px;
612
+ font-weight: 500;
613
+ cursor: pointer;
614
+ transition: border-color 0.15s;
615
+ }
616
+
617
+ .feedtack-modal-pin-btn:hover {
618
+ border-color: var(--ft-primary);
619
+ color: var(--ft-primary);
620
+ }
621
+
622
+ .feedtack-modal-thread-view {
623
+ display: flex;
624
+ flex-direction: column;
625
+ gap: 10px;
626
+ }
627
+
628
+ .feedtack-modal-back {
629
+ background: none;
630
+ border: none;
631
+ font-size: 13px;
632
+ color: var(--ft-primary);
633
+ cursor: pointer;
634
+ padding: 0;
635
+ text-align: left;
636
+ }
637
+
638
+ .feedtack-modal-thread-content {
639
+ display: flex;
640
+ flex-direction: column;
641
+ gap: 4px;
642
+ font-size: 13px;
643
+ }
644
+
645
+ .feedtack-modal-reply {
646
+ border-top: 1px solid var(--ft-border);
647
+ padding-top: 8px;
648
+ font-size: 12px;
649
+ }
650
+
651
+ .feedtack-reply-author {
652
+ font-weight: 600;
653
+ }
654
+
655
+ .feedtack-modal-actions {
656
+ display: flex;
657
+ gap: 6px;
658
+ flex-wrap: wrap;
659
+ }
660
+
661
+ @media (max-width: 480px) {
662
+ .feedtack-modal {
663
+ right: 0;
664
+ bottom: 64px;
665
+ width: 100vw;
666
+ max-height: 85vh;
667
+ margin: 0;
668
+ border-radius: var(--ft-radius) var(--ft-radius) 0 0;
669
+ border-left: none;
670
+ border-right: none;
671
+ border-bottom: none;
672
+ }
673
+ }
674
+ `;
675
+
676
+ // src/ui/styles.ts
677
+ var FEEDTACK_DEFAULT_TOKENS = `
678
+ #feedtack-root, .feedtack-form, .feedtack-thread, .feedtack-modal {
679
+ --ft-primary: #2563eb;
680
+ --ft-primary-hover: #1d4ed8;
681
+ --ft-bg: #ffffff;
682
+ --ft-surface: #f9fafb;
683
+ --ft-text: #111827;
684
+ --ft-text-muted: #6b7280;
685
+ --ft-border: #e5e7eb;
686
+ --ft-radius: 8px;
687
+ --ft-error: #ef4444;
688
+ --ft-badge: #f59e0b;
689
+ }
690
+ `;
691
+ var FEEDTACK_STYLES = `
692
+ #feedtack-root * {
693
+ box-sizing: border-box;
694
+ margin: 0;
695
+ padding: 0;
696
+ font-family: system-ui, -apple-system, sans-serif;
697
+ line-height: 1.5;
698
+ }
699
+
700
+ .feedtack-btn {
701
+ position: fixed;
702
+ bottom: 24px;
703
+ right: 24px;
704
+ z-index: 2147483640;
705
+ background: var(--ft-text);
706
+ color: var(--ft-bg);
707
+ border: none;
708
+ border-radius: var(--ft-radius);
709
+ padding: 8px 14px;
710
+ font-size: 13px;
711
+ font-weight: 500;
712
+ cursor: pointer;
713
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
714
+ display: flex;
715
+ align-items: center;
716
+ gap: 6px;
717
+ transition: background 0.15s;
718
+ }
719
+
720
+ .feedtack-btn:hover {
721
+ opacity: 0.85;
722
+ }
723
+
724
+ .feedtack-btn.active {
725
+ background: var(--ft-primary);
726
+ }
727
+
728
+ .feedtack-crosshair * {
729
+ cursor: crosshair !important;
730
+ }
731
+
732
+ .feedtack-pin-marker {
733
+ position: absolute;
734
+ z-index: 2147483641;
735
+ width: 24px;
736
+ height: 24px;
737
+ border-radius: 50% 50% 50% 0;
738
+ transform: translate(-50%, -100%) rotate(-45deg);
739
+ transform-origin: bottom center;
740
+ border: 2px solid rgba(255,255,255,0.8);
741
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
742
+ cursor: pointer;
743
+ pointer-events: all;
744
+ }
745
+
746
+ .feedtack-pin-resolved { opacity: 0.6; }
747
+
748
+ .feedtack-pin-icon {
749
+ position: absolute;
750
+ inset: 0;
751
+ display: flex;
752
+ align-items: center;
753
+ justify-content: center;
754
+ transform: rotate(45deg);
755
+ font-size: 12px;
756
+ font-weight: 700;
757
+ color: #fff;
758
+ line-height: 1;
759
+ pointer-events: none;
760
+ }
761
+
762
+ .feedtack-pin-badge {
763
+ position: absolute;
764
+ top: -4px;
765
+ right: -4px;
766
+ width: 10px;
767
+ height: 10px;
768
+ background: var(--ft-badge);
769
+ border-radius: 50%;
770
+ border: 1.5px solid var(--ft-bg);
771
+ }
772
+
773
+ .feedtack-color-picker {
774
+ display: flex;
775
+ gap: 6px;
776
+ padding: 8px;
777
+ background: var(--ft-bg) !important;
778
+ border-radius: var(--ft-radius);
779
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
780
+ position: fixed;
781
+ bottom: 72px;
782
+ right: 24px;
783
+ z-index: 2147483641;
784
+ }
785
+
786
+ .feedtack-color-swatch {
787
+ width: 20px;
788
+ height: 20px;
789
+ border-radius: 50%;
790
+ border: 2px solid transparent;
791
+ cursor: pointer;
792
+ transition: transform 0.1s;
793
+ }
794
+
795
+ .feedtack-color-swatch.selected {
796
+ border-color: var(--ft-text);
797
+ transform: scale(1.15);
798
+ }
799
+
800
+ .feedtack-form {
801
+ position: absolute;
802
+ z-index: 2147483642;
803
+ background: var(--ft-bg) !important;
804
+ border-radius: calc(var(--ft-radius) + 2px);
805
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
806
+ padding: 16px;
807
+ width: 280px;
808
+ display: flex;
809
+ flex-direction: column;
810
+ gap: 10px;
811
+ }
812
+
813
+ .feedtack-form textarea {
814
+ width: 100%;
815
+ border: 1.5px solid var(--ft-border);
816
+ border-radius: var(--ft-radius);
817
+ padding: 8px;
818
+ font-size: 13px;
819
+ resize: vertical;
820
+ min-height: 80px;
821
+ outline: none;
822
+ background: var(--ft-surface);
823
+ color: var(--ft-text);
824
+ }
825
+
826
+ .feedtack-form textarea:focus {
827
+ border-color: var(--ft-primary);
828
+ }
829
+
830
+ .feedtack-form textarea.error {
831
+ border-color: var(--ft-error);
832
+ }
833
+
834
+ .feedtack-error-msg {
835
+ font-size: 12px;
836
+ color: var(--ft-error);
837
+ }
838
+
839
+ .feedtack-sentiment {
840
+ display: flex;
841
+ gap: 8px;
842
+ }
843
+
844
+ .feedtack-sentiment button {
845
+ flex: 1;
846
+ padding: 6px 10px;
847
+ border: 1.5px solid var(--ft-border);
848
+ border-radius: var(--ft-radius);
849
+ background: var(--ft-bg);
850
+ color: var(--ft-text);
851
+ font-size: 12px;
852
+ cursor: pointer;
853
+ transition: all 0.1s;
854
+ }
855
+
856
+ .feedtack-sentiment button.selected {
857
+ border-color: var(--ft-primary);
858
+ background: var(--ft-surface);
859
+ color: var(--ft-primary);
860
+ }
861
+
862
+ .feedtack-form-actions {
863
+ display: flex;
864
+ gap: 8px;
865
+ justify-content: flex-end;
866
+ }
867
+
868
+ .feedtack-btn-cancel {
869
+ padding: 6px 12px;
870
+ border: 1.5px solid var(--ft-border);
871
+ border-radius: var(--ft-radius);
872
+ background: var(--ft-bg);
873
+ color: var(--ft-text);
874
+ font-size: 13px;
875
+ cursor: pointer;
876
+ }
877
+
878
+ .feedtack-btn-submit {
879
+ padding: 6px 12px;
880
+ border: none;
881
+ border-radius: var(--ft-radius);
882
+ background: var(--ft-primary);
883
+ color: #fff;
884
+ font-size: 13px;
885
+ font-weight: 500;
886
+ cursor: pointer;
887
+ }
888
+
889
+ .feedtack-btn-submit:disabled {
890
+ opacity: 0.5;
891
+ cursor: not-allowed;
892
+ }
893
+
894
+ .feedtack-thread {
895
+ position: absolute;
896
+ z-index: 2147483642;
897
+ background: var(--ft-bg) !important;
898
+ border-radius: calc(var(--ft-radius) + 2px);
899
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
900
+ padding: 16px;
901
+ width: 300px;
902
+ max-height: 400px;
903
+ overflow-y: auto;
904
+ display: flex;
905
+ flex-direction: column;
906
+ gap: 10px;
907
+ }
908
+
909
+ .feedtack-sr-only {
910
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
911
+ overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
912
+ }
913
+
914
+ ` + FEEDTACK_MODAL_STYLES;
915
+
916
+ // src/core/dom.ts
917
+ function injectStyles() {
918
+ if (document.getElementById("feedtack-styles")) return null;
919
+ const style = document.createElement("style");
920
+ style.id = "feedtack-styles";
921
+ style.textContent = FEEDTACK_DEFAULT_TOKENS + FEEDTACK_STYLES;
922
+ document.head.appendChild(style);
923
+ return style;
924
+ }
925
+ function injectRoot() {
926
+ const root = document.createElement("div");
927
+ root.id = "feedtack-root";
928
+ document.body.appendChild(root);
929
+ return root;
930
+ }
931
+ function applyTheme(theme) {
932
+ const root = document.getElementById("feedtack-root");
933
+ if (!root) return;
934
+ const tokens = themeToCSS(theme);
935
+ for (const [k, v] of Object.entries(tokens)) {
936
+ root.style.setProperty(k, v);
937
+ }
938
+ }
939
+ function setCrosshair(active) {
940
+ if (active) {
941
+ document.documentElement.classList.add("feedtack-crosshair");
942
+ } else {
943
+ document.documentElement.classList.remove("feedtack-crosshair");
944
+ }
945
+ }
946
+ function attachSpaNavigation(onUpdate) {
947
+ const origPush = history.pushState.bind(history);
948
+ const origReplace = history.replaceState.bind(history);
949
+ history.pushState = (...args) => {
950
+ origPush(...args);
951
+ queueMicrotask(onUpdate);
952
+ };
953
+ history.replaceState = (...args) => {
954
+ origReplace(...args);
955
+ queueMicrotask(onUpdate);
956
+ };
957
+ window.addEventListener("popstate", onUpdate);
958
+ return {
959
+ origPush,
960
+ origReplace,
961
+ detach() {
962
+ window.removeEventListener("popstate", onUpdate);
963
+ history.pushState = origPush;
964
+ history.replaceState = origReplace;
965
+ }
966
+ };
967
+ }
968
+ var FEEDTACK_UI_SELECTOR = "#feedtack-root, .feedtack-form, .feedtack-color-picker";
969
+
970
+ // src/core/flush.ts
971
+ var IDLE_EVENTS = ["mousemove", "keydown", "scroll", "touchstart"];
972
+ function attachFlush(getItems, getPathname, onFlush, idleMs) {
973
+ const flushedPaths = /* @__PURE__ */ new Set();
974
+ let idleTimer = null;
975
+ function flushPath(path) {
976
+ if (flushedPaths.has(path)) return;
977
+ const pageItems = getItems().filter((i) => i.payload.page.pathname === path);
978
+ if (pageItems.length === 0) return;
979
+ flushedPaths.add(path);
980
+ onFlush({ pathname: path, items: pageItems });
981
+ }
982
+ function clearFlushed(path) {
983
+ flushedPaths.delete(path);
984
+ }
985
+ const onBeforeUnload = () => flushPath(getPathname());
986
+ window.addEventListener("beforeunload", onBeforeUnload);
987
+ let idleReset = null;
988
+ if (idleMs > 0) {
989
+ idleReset = () => {
990
+ if (idleTimer) clearTimeout(idleTimer);
991
+ idleTimer = setTimeout(() => flushPath(getPathname()), idleMs);
992
+ };
993
+ for (const e of IDLE_EVENTS) {
994
+ window.addEventListener(e, idleReset, { passive: true });
995
+ }
996
+ idleReset();
997
+ }
998
+ return {
999
+ flushPath,
1000
+ clearFlushed,
1001
+ detach() {
1002
+ window.removeEventListener("beforeunload", onBeforeUnload);
1003
+ if (idleTimer) clearTimeout(idleTimer);
1004
+ if (idleReset) {
1005
+ for (const e of IDLE_EVENTS) {
1006
+ window.removeEventListener(e, idleReset);
1007
+ }
1008
+ }
1009
+ }
1010
+ };
1011
+ }
1012
+
1013
+ // src/core/input.ts
1014
+ function createKeydownHandler(hotkey, getState, setState, openModal, deactivatePinMode) {
1015
+ const key = hotkey.toUpperCase();
1016
+ return (e) => {
1017
+ if (e.key === key && e.shiftKey) openModal();
1018
+ if (e.key === "Escape") deactivatePinMode();
1019
+ const s = getState();
1020
+ if (s.isPinModeActive && !s.openThreadId && !s.isModalOpen && !s.showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
1021
+ e.preventDefault();
1022
+ const idx = PIN_PALETTE.indexOf(
1023
+ s.selectedColor
1024
+ );
1025
+ const dir = e.key === "ArrowRight" ? 1 : -1;
1026
+ setState({
1027
+ selectedColor: PIN_PALETTE[(idx + dir + PIN_PALETTE.length) % PIN_PALETTE.length]
1028
+ });
1029
+ }
1030
+ };
1031
+ }
1032
+ function placePin(coords, target, getState, setState) {
1033
+ if (target.closest(FEEDTACK_UI_SELECTOR)) return;
1034
+ const pin = {
1035
+ color: getState().selectedColor,
1036
+ ...getPinCoords(coords),
1037
+ target: getTargetMeta(target)
1038
+ };
1039
+ setState({ pendingPins: [...getState().pendingPins, pin], showForm: true });
1040
+ }
1041
+ function attachInputListeners(getState, setState, hotkey, openModal, deactivatePinMode) {
1042
+ const keydown = createKeydownHandler(
1043
+ hotkey,
1044
+ getState,
1045
+ setState,
1046
+ openModal,
1047
+ deactivatePinMode
1048
+ );
1049
+ window.addEventListener("keydown", keydown);
1050
+ const click = (e) => {
1051
+ if (!getState().isPinModeActive) return;
1052
+ e.preventDefault();
1053
+ e.stopPropagation();
1054
+ placePin(e, e.target, getState, setState);
1055
+ };
1056
+ const touchEnd = (e) => {
1057
+ if (!getState().isPinModeActive) return;
1058
+ const t = e.changedTouches[0];
1059
+ if (!t) return;
1060
+ const el = document.elementFromPoint(t.clientX, t.clientY);
1061
+ if (!el) return;
1062
+ e.preventDefault();
1063
+ placePin(t, el, getState, setState);
1064
+ };
1065
+ document.addEventListener("click", click, true);
1066
+ document.addEventListener("touchend", touchEnd, true);
1067
+ return {
1068
+ detachKeyboard() {
1069
+ window.removeEventListener("keydown", keydown);
1070
+ },
1071
+ detachClick() {
1072
+ document.removeEventListener("click", click, true);
1073
+ document.removeEventListener("touchend", touchEnd, true);
1074
+ }
1075
+ };
1076
+ }
1077
+
1078
+ // src/core/FeedtackEngine.ts
1079
+ var DEFAULT_IDLE_MS = 5 * 60 * 1e3;
1080
+ var FeedtackEngine = class {
1081
+ constructor(opts) {
1082
+ this.listeners = /* @__PURE__ */ new Set();
1083
+ this.styleEl = null;
1084
+ this.rootEl = null;
1085
+ this.spaNav = null;
1086
+ this.flushCtrl = null;
1087
+ this.inputHandles = null;
1088
+ this.opts = opts;
1089
+ const pathname = typeof window === "undefined" ? "/" : window.location.pathname;
1090
+ this.state = {
1091
+ isPinModeActive: false,
1092
+ pendingPins: [],
1093
+ selectedColor: PIN_PALETTE[0],
1094
+ showForm: false,
1095
+ comment: "",
1096
+ sentiment: null,
1097
+ commentError: false,
1098
+ submitting: false,
1099
+ feedbackItems: [],
1100
+ siteFeedback: [],
1101
+ pageFeedback: [],
1102
+ loading: true,
1103
+ openThreadId: null,
1104
+ replyBody: "",
1105
+ isModalOpen: false,
1106
+ composeScope: "site",
1107
+ pathname
1108
+ };
1109
+ this.actionCtx = {
1110
+ adapter: opts.adapter,
1111
+ currentUser: opts.currentUser,
1112
+ onError: opts.onError,
1113
+ rescopeRoles: opts.rescopeRoles,
1114
+ getState: () => this.state,
1115
+ setState: (p) => this.setState(p),
1116
+ getCurrentScope: () => this.getCurrentScope(),
1117
+ deactivatePinMode: () => this.deactivatePinMode(),
1118
+ flushCtrl: null
1119
+ };
1120
+ }
1121
+ // ── State ──────────────────────────────────────────────────────────────
1122
+ getState() {
1123
+ return this.state;
1124
+ }
1125
+ subscribe(listener) {
1126
+ this.listeners.add(listener);
1127
+ return () => {
1128
+ this.listeners.delete(listener);
1129
+ };
1130
+ }
1131
+ setState(partial) {
1132
+ const keys = Object.keys(partial);
1133
+ let changed = false;
1134
+ for (const k of keys) {
1135
+ if (this.state[k] !== partial[k]) {
1136
+ changed = true;
1137
+ break;
1138
+ }
1139
+ }
1140
+ if (!changed) return;
1141
+ this.state = { ...this.state, ...partial };
1142
+ for (const fn of this.listeners) fn(this.state, keys);
1143
+ }
1144
+ // ── Lifecycle ──────────────────────────────────────────────────────────
1145
+ mount() {
1146
+ if (this.opts.disabled) return;
1147
+ this.styleEl = injectStyles();
1148
+ this.rootEl = injectRoot();
1149
+ if (this.opts.theme) applyTheme(this.opts.theme);
1150
+ this.spaNav = attachSpaNavigation(() => this.onNavUpdate());
1151
+ this.inputHandles = attachInputListeners(
1152
+ () => this.state,
1153
+ (p) => this.setState(p),
1154
+ this.opts.hotkey ?? "p",
1155
+ () => this.openModal(),
1156
+ () => this.deactivatePinMode()
1157
+ );
1158
+ if (this.opts.onFlush) {
1159
+ this.flushCtrl = attachFlush(
1160
+ () => this.state.feedbackItems,
1161
+ () => this.state.pathname,
1162
+ this.opts.onFlush,
1163
+ this.opts.flushIdleMs ?? DEFAULT_IDLE_MS
1164
+ );
1165
+ this.actionCtx.flushCtrl = this.flushCtrl;
1166
+ }
1167
+ loadFeedback(this.actionCtx);
1168
+ }
1169
+ destroy() {
1170
+ this.spaNav?.detach();
1171
+ this.flushCtrl?.detach();
1172
+ this.inputHandles?.detachKeyboard();
1173
+ this.inputHandles?.detachClick();
1174
+ this.inputHandles = null;
1175
+ setCrosshair(false);
1176
+ this.styleEl?.remove();
1177
+ this.rootEl?.remove();
1178
+ this.styleEl = null;
1179
+ this.rootEl = null;
1180
+ }
1181
+ onNavUpdate() {
1182
+ const p = window.location.pathname;
1183
+ if (p === this.state.pathname) return;
1184
+ this.flushCtrl?.flushPath(this.state.pathname);
1185
+ this.setState({ pathname: p });
1186
+ loadFeedback(this.actionCtx);
1187
+ }
1188
+ // ── Pin mode ──────────────────────────────────────────────────────────
1189
+ activatePinMode() {
1190
+ this.setState({ isPinModeActive: true });
1191
+ setCrosshair(true);
1192
+ }
1193
+ deactivatePinMode() {
1194
+ this.setState({
1195
+ isPinModeActive: false,
1196
+ pendingPins: [],
1197
+ showForm: false,
1198
+ comment: "",
1199
+ sentiment: null,
1200
+ commentError: false,
1201
+ openThreadId: null
1202
+ });
1203
+ setCrosshair(false);
1204
+ }
1205
+ // ── Setters ───────────────────────────────────────────────────────────
1206
+ setComment(v) {
1207
+ this.setState({ comment: v });
1208
+ }
1209
+ setSentiment(v) {
1210
+ this.setState({ sentiment: v });
1211
+ }
1212
+ setCommentError(v) {
1213
+ this.setState({ commentError: v });
1214
+ }
1215
+ setSelectedColor(c) {
1216
+ this.setState({ selectedColor: c });
1217
+ }
1218
+ setOpenThreadId(id) {
1219
+ this.setState({ openThreadId: id });
1220
+ }
1221
+ setReplyBody(v) {
1222
+ this.setState({ replyBody: v });
1223
+ }
1224
+ setComposeScope(s) {
1225
+ this.setState({ composeScope: s });
1226
+ }
1227
+ openModal() {
1228
+ this.setState({ isModalOpen: true });
1229
+ }
1230
+ closeModal() {
1231
+ this.setState({ isModalOpen: false });
1232
+ }
1233
+ // ── Derived ───────────────────────────────────────────────────────────
1234
+ getCurrentScope() {
1235
+ const s = this.state;
1236
+ return s.isPinModeActive || s.pendingPins.length > 0 ? "element" : s.composeScope;
1237
+ }
1238
+ isArchivedForUser(item) {
1239
+ return item.archives.some(
1240
+ (a) => a.archivedBy.id === this.opts.currentUser.id
1241
+ );
1242
+ }
1243
+ hasUnread(item) {
1244
+ return item.replies.length > 0;
1245
+ }
1246
+ hasValidPins(item) {
1247
+ return Array.isArray(item.payload?.pins) && item.payload.pins.length > 0;
1248
+ }
1249
+ // ── Actions (delegated) ───────────────────────────────────────────────
1250
+ handleSubmit() {
1251
+ return handleSubmit(this.actionCtx);
1252
+ }
1253
+ handleModalSubmit() {
1254
+ return handleModalSubmit(this.actionCtx);
1255
+ }
1256
+ handleReply(id) {
1257
+ return handleReply(this.actionCtx, id);
1258
+ }
1259
+ handleResolve(id) {
1260
+ return handleResolve(this.actionCtx, id);
1261
+ }
1262
+ handleArchive(id) {
1263
+ return handleArchive(this.actionCtx, id);
1264
+ }
1265
+ };
1266
+
1267
+ // src/types/adapter.ts
1268
+ function isContentEditAdapter(adapter) {
1269
+ return isContentAdapter(adapter) && typeof adapter.loadFields === "function" && typeof adapter.saveField === "function";
1270
+ }
1271
+ function warnIfNotContentEditAdapter(adapter, method) {
1272
+ if (DEV2 && !isContentEditAdapter(adapter)) {
1273
+ console.warn(
1274
+ `[feedtack] ${method}() called but the adapter does not implement ContentEditAdapter. Content editing features are unavailable with this adapter.`
1275
+ );
1276
+ }
1277
+ }
1278
+ var DEV2 = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
1279
+ function isContentAdapter(adapter) {
1280
+ return typeof adapter === "object" && adapter !== null && typeof adapter.approve === "function" && typeof adapter.revokeApproval === "function" && typeof adapter.loadApprovals === "function";
1281
+ }
1282
+ function warnIfNotContentAdapter(adapter, method) {
1283
+ if (DEV2 && !isContentAdapter(adapter)) {
1284
+ console.warn(
1285
+ `[feedtack] ${method}() called but the adapter does not implement ContentAdapter. Content approval features are unavailable with this adapter.`
1286
+ );
1287
+ }
1288
+ }
1289
+
1290
+ export {
1291
+ scanFields,
1292
+ hashField,
1293
+ getViewportMeta,
1294
+ getPageMeta,
1295
+ getDeviceMeta,
1296
+ getPinCoords,
1297
+ getCSSSelector,
1298
+ getTargetMeta,
1299
+ PIN_PALETTE,
1300
+ SCHEMA_VERSION,
1301
+ generateId,
1302
+ themeToCSS,
1303
+ FeedtackEngine,
1304
+ isContentEditAdapter,
1305
+ warnIfNotContentEditAdapter,
1306
+ isContentAdapter,
1307
+ warnIfNotContentAdapter
1308
+ };