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