feedtack 0.1.1 → 0.2.1

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,237 @@
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,
204
+ y: rect.y,
205
+ width: rect.width,
206
+ height: rect.height
207
+ }
208
+ };
209
+ }
210
+
211
+ // src/types/payload.ts
212
+ var SCHEMA_VERSION = "1.0.0";
213
+
214
+ // src/types/theme.ts
215
+ function themeToCSS(theme) {
216
+ const map = {};
217
+ if (theme.primary) map["--ft-primary"] = theme.primary;
218
+ if (theme.background) map["--ft-bg"] = theme.background;
219
+ if (theme.surface) map["--ft-surface"] = theme.surface;
220
+ if (theme.text) map["--ft-text"] = theme.text;
221
+ if (theme.textMuted) map["--ft-text-muted"] = theme.textMuted;
222
+ if (theme.border) map["--ft-border"] = theme.border;
223
+ if (theme.radius) map["--ft-radius"] = theme.radius;
224
+ if (theme.badge) map["--ft-badge"] = theme.badge;
225
+ return map;
226
+ }
227
+
228
+ export {
229
+ getViewportMeta,
230
+ getPageMeta,
231
+ getDeviceMeta,
232
+ getPinCoords,
233
+ getCSSSelector,
234
+ getTargetMeta,
235
+ SCHEMA_VERSION,
236
+ themeToCSS
237
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FeedtackAdapter, a as FeedtackPayload, b as FeedtackReply, c as FeedtackResolution, d as FeedtackFilter, e as FeedbackItem, f as FeedtackDeviceMeta, g as FeedtackPageMeta, h as FeedtackViewportMeta, i as FeedtackPinTarget } from './theme-C_JZCVVA.js';
2
- export { j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackTheme, o as FeedtackUser, S as SCHEMA_VERSION, t as themeToCSS } from './theme-C_JZCVVA.js';
1
+ import { F as FeedtackAdapter, a as FeedtackPayload, b as FeedtackReply, c as FeedtackResolution, d as FeedtackFilter, e as FeedbackItem, f as FeedtackDeviceMeta, g as FeedtackPageMeta, h as FeedtackViewportMeta, i as FeedtackPinTarget } from './theme-C-uctIoI.js';
2
+ export { A as AncestorNode, j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackTheme, o as FeedtackUser, S as SCHEMA_VERSION, t as themeToCSS } from './theme-C-uctIoI.js';
3
3
 
4
4
  /** Development adapter — logs all operations to the browser console */
5
5
  declare class ConsoleAdapter implements FeedtackAdapter {
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  getTargetMeta,
8
8
  getViewportMeta,
9
9
  themeToCSS
10
- } from "./chunk-XVWG3PLK.js";
10
+ } from "./chunk-NCW2V5JL.js";
11
11
 
12
12
  // src/adapters/ConsoleAdapter.ts
13
13
  var ConsoleAdapter = class {
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
- import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme } from '../theme-C_JZCVVA.js';
3
+ import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme } from '../theme-C-uctIoI.js';
4
4
 
5
5
  interface FeedtackClasses {
6
6
  button?: string;
@@ -23,8 +23,9 @@ interface FeedtackProviderProps {
23
23
  classes?: FeedtackClasses;
24
24
  sentimentLabels?: FeedtackSentimentLabels;
25
25
  onError?: (err: Error) => void;
26
+ disabled?: boolean;
26
27
  }
27
- declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
28
+ declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, disabled, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
28
29
 
29
30
  interface FeedtackContextValue {
30
31
  activatePinMode: () => void;
@@ -6,7 +6,7 @@ import {
6
6
  getTargetMeta,
7
7
  getViewportMeta,
8
8
  themeToCSS
9
- } from "../chunk-XVWG3PLK.js";
9
+ } from "../chunk-NCW2V5JL.js";
10
10
 
11
11
  // src/ui/colors.ts
12
12
  var PIN_PALETTE = [
@@ -24,16 +24,6 @@ var PIN_PALETTE = [
24
24
  // pink
25
25
  ];
26
26
 
27
- // src/react/context.ts
28
- import { createContext, useContext } from "react";
29
- var FeedtackContext = createContext(null);
30
- function useFeedtackContext() {
31
- const ctx = useContext(FeedtackContext);
32
- if (!ctx)
33
- throw new Error("useFeedtack must be used inside <FeedtackProvider>");
34
- return ctx;
35
- }
36
-
37
27
  // src/react/utils.ts
38
28
  function generateId() {
39
29
  return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
@@ -55,8 +45,98 @@ function cx(...parts) {
55
45
  return parts.filter(Boolean).join(" ");
56
46
  }
57
47
 
58
- // src/react/ThreadPanel.tsx
48
+ // src/react/CommentForm.tsx
59
49
  import { jsx, jsxs } from "react/jsx-runtime";
50
+ function CommentForm({
51
+ comment,
52
+ commentError,
53
+ sentiment,
54
+ submitting,
55
+ formPos,
56
+ classes,
57
+ sentimentLabels,
58
+ onCommentChange,
59
+ onSentimentChange,
60
+ onSubmit,
61
+ onCancel
62
+ }) {
63
+ return /* @__PURE__ */ jsxs(
64
+ "div",
65
+ {
66
+ className: cx("feedtack-form", classes.form),
67
+ style: { position: "fixed", ...formPos },
68
+ children: [
69
+ /* @__PURE__ */ jsx(
70
+ "textarea",
71
+ {
72
+ className: commentError ? "error" : "",
73
+ placeholder: "What's the issue? (required)",
74
+ value: comment,
75
+ onChange: (e) => onCommentChange(e.target.value),
76
+ ref: (el) => el?.focus()
77
+ }
78
+ ),
79
+ commentError && /* @__PURE__ */ jsx("span", { className: "feedtack-error-msg", children: "Comment is required" }),
80
+ /* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
81
+ /* @__PURE__ */ jsx(
82
+ "button",
83
+ {
84
+ type: "button",
85
+ className: sentiment === "satisfied" ? "selected" : "",
86
+ onClick: () => onSentimentChange(sentiment === "satisfied" ? null : "satisfied"),
87
+ children: sentimentLabels.satisfied ?? "\u{1F60A} Satisfied"
88
+ }
89
+ ),
90
+ /* @__PURE__ */ jsx(
91
+ "button",
92
+ {
93
+ type: "button",
94
+ className: sentiment === "dissatisfied" ? "selected" : "",
95
+ onClick: () => onSentimentChange(
96
+ sentiment === "dissatisfied" ? null : "dissatisfied"
97
+ ),
98
+ children: sentimentLabels.dissatisfied ?? "\u{1F61E} Dissatisfied"
99
+ }
100
+ )
101
+ ] }),
102
+ /* @__PURE__ */ jsxs("div", { className: "feedtack-form-actions", children: [
103
+ /* @__PURE__ */ jsx(
104
+ "button",
105
+ {
106
+ type: "button",
107
+ className: "feedtack-btn-cancel",
108
+ onClick: onCancel,
109
+ children: "Cancel"
110
+ }
111
+ ),
112
+ /* @__PURE__ */ jsx(
113
+ "button",
114
+ {
115
+ type: "button",
116
+ className: "feedtack-btn-submit",
117
+ onClick: onSubmit,
118
+ disabled: submitting,
119
+ children: submitting ? "Sending\u2026" : "Submit"
120
+ }
121
+ )
122
+ ] })
123
+ ]
124
+ }
125
+ );
126
+ }
127
+
128
+ // src/react/context.ts
129
+ import { createContext, useContext } from "react";
130
+ var FeedtackContext = createContext(null);
131
+ function useFeedtackContext() {
132
+ const ctx = useContext(FeedtackContext);
133
+ if (!ctx)
134
+ throw new Error("useFeedtack must be used inside <FeedtackProvider>");
135
+ return ctx;
136
+ }
137
+
138
+ // src/react/ThreadPanel.tsx
139
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
60
140
  function ThreadPanel({
61
141
  item,
62
142
  replyBody,
@@ -67,28 +147,29 @@ function ThreadPanel({
67
147
  onClose,
68
148
  className
69
149
  }) {
70
- const pin = item.payload.pins[0];
150
+ const pin = item.payload?.pins?.[0];
151
+ if (!pin) return null;
71
152
  const pos = getAnchoredPosition(pin.x, pin.y);
72
- return /* @__PURE__ */ jsxs(
153
+ return /* @__PURE__ */ jsxs2(
73
154
  "div",
74
155
  {
75
156
  className: cx("feedtack-thread", className),
76
157
  style: { position: "fixed", ...pos },
77
158
  children: [
78
- /* @__PURE__ */ jsx("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
79
- /* @__PURE__ */ jsx("p", { style: { fontSize: 13 }, children: item.payload.comment }),
80
- item.replies.map((r) => /* @__PURE__ */ jsxs(
159
+ /* @__PURE__ */ jsx2("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
160
+ /* @__PURE__ */ jsx2("p", { style: { fontSize: 13 }, children: item.payload.comment }),
161
+ item.replies.map((r) => /* @__PURE__ */ jsxs2(
81
162
  "div",
82
163
  {
83
164
  style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 },
84
165
  children: [
85
- /* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
86
- /* @__PURE__ */ jsx("p", { style: { fontSize: 12 }, children: r.body })
166
+ /* @__PURE__ */ jsx2("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
167
+ /* @__PURE__ */ jsx2("p", { style: { fontSize: 12 }, children: r.body })
87
168
  ]
88
169
  },
89
170
  r.id
90
171
  )),
91
- /* @__PURE__ */ jsx(
172
+ /* @__PURE__ */ jsx2(
92
173
  "textarea",
93
174
  {
94
175
  placeholder: "Reply\u2026",
@@ -104,8 +185,8 @@ function ThreadPanel({
104
185
  }
105
186
  }
106
187
  ),
107
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
108
- /* @__PURE__ */ jsx(
188
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
189
+ /* @__PURE__ */ jsx2(
109
190
  "button",
110
191
  {
111
192
  type: "button",
@@ -115,7 +196,7 @@ function ThreadPanel({
115
196
  children: "Reply"
116
197
  }
117
198
  ),
118
- /* @__PURE__ */ jsx(
199
+ /* @__PURE__ */ jsx2(
119
200
  "button",
120
201
  {
121
202
  type: "button",
@@ -125,7 +206,7 @@ function ThreadPanel({
125
206
  children: "Mark Resolved"
126
207
  }
127
208
  ),
128
- /* @__PURE__ */ jsx(
209
+ /* @__PURE__ */ jsx2(
129
210
  "button",
130
211
  {
131
212
  type: "button",
@@ -135,7 +216,7 @@ function ThreadPanel({
135
216
  children: "Archive"
136
217
  }
137
218
  ),
138
- /* @__PURE__ */ jsx(
219
+ /* @__PURE__ */ jsx2(
139
220
  "button",
140
221
  {
141
222
  type: "button",
@@ -385,9 +466,10 @@ var FEEDTACK_STYLES = `
385
466
  `;
386
467
 
387
468
  // src/react/useFeedtackDom.ts
388
- function useFeedtackDom(theme) {
469
+ function useFeedtackDom(theme, disabled) {
389
470
  const rootRef = useRef(null);
390
471
  useEffect(() => {
472
+ if (disabled) return;
391
473
  if (document.getElementById("feedtack-styles")) return;
392
474
  const style = document.createElement("style");
393
475
  style.id = "feedtack-styles";
@@ -396,8 +478,9 @@ function useFeedtackDom(theme) {
396
478
  return () => {
397
479
  style.remove();
398
480
  };
399
- }, []);
481
+ }, [disabled]);
400
482
  useEffect(() => {
483
+ if (disabled) return;
401
484
  const root = document.createElement("div");
402
485
  root.id = "feedtack-root";
403
486
  document.body.appendChild(root);
@@ -405,21 +488,22 @@ function useFeedtackDom(theme) {
405
488
  return () => {
406
489
  root.remove();
407
490
  };
408
- }, []);
491
+ }, [disabled]);
409
492
  useEffect(() => {
493
+ if (disabled) return;
410
494
  const root = document.getElementById("feedtack-root");
411
495
  if (!root || !theme) return;
412
496
  const tokens = themeToCSS(theme);
413
497
  for (const [k, v] of Object.entries(tokens)) {
414
498
  root.style.setProperty(k, v);
415
499
  }
416
- }, [theme]);
500
+ }, [theme, disabled]);
417
501
  return rootRef;
418
502
  }
419
503
 
420
504
  // src/react/usePinMode.ts
421
505
  import { useCallback, useEffect as useEffect2, useState } from "react";
422
- function usePinMode({ hotkey, onDeactivate }) {
506
+ function usePinMode({ hotkey, onDeactivate, disabled }) {
423
507
  const [isActive, setIsActive] = useState(false);
424
508
  const [pendingPins, setPendingPins] = useState([]);
425
509
  const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
@@ -440,6 +524,7 @@ function usePinMode({ hotkey, onDeactivate }) {
440
524
  return () => document.documentElement.classList.remove("feedtack-crosshair");
441
525
  }, [isActive]);
442
526
  useEffect2(() => {
527
+ if (disabled) return;
443
528
  const handler = (e) => {
444
529
  if (e.key === hotkey.toUpperCase() && e.shiftKey) {
445
530
  setIsActive((prev) => !prev);
@@ -458,7 +543,7 @@ function usePinMode({ hotkey, onDeactivate }) {
458
543
  };
459
544
  window.addEventListener("keydown", handler);
460
545
  return () => window.removeEventListener("keydown", handler);
461
- }, [hotkey, deactivate, isActive]);
546
+ }, [hotkey, deactivate, isActive, disabled]);
462
547
  const handlePageClick = useCallback(
463
548
  (e) => {
464
549
  if (!isActive) return;
@@ -480,9 +565,10 @@ function usePinMode({ hotkey, onDeactivate }) {
480
565
  [isActive, selectedColor]
481
566
  );
482
567
  useEffect2(() => {
568
+ if (disabled) return;
483
569
  document.addEventListener("click", handlePageClick, true);
484
570
  return () => document.removeEventListener("click", handlePageClick, true);
485
- }, [handlePageClick]);
571
+ }, [handlePageClick, disabled]);
486
572
  return {
487
573
  isActive,
488
574
  activate,
@@ -500,9 +586,10 @@ function useFeedtackState({
500
586
  currentUser,
501
587
  hotkey,
502
588
  theme,
503
- onError
589
+ onError,
590
+ disabled
504
591
  }) {
505
- useFeedtackDom(theme);
592
+ useFeedtackDom(theme, disabled);
506
593
  const [pathname, setPathname] = useState2(() => window.location.pathname);
507
594
  useEffect3(() => {
508
595
  const update = () => setPathname(window.location.pathname);
@@ -510,11 +597,11 @@ function useFeedtackState({
510
597
  const origReplace = history.replaceState.bind(history);
511
598
  history.pushState = (...args) => {
512
599
  origPush(...args);
513
- update();
600
+ queueMicrotask(update);
514
601
  };
515
602
  history.replaceState = (...args) => {
516
603
  origReplace(...args);
517
- update();
604
+ queueMicrotask(update);
518
605
  };
519
606
  window.addEventListener("popstate", update);
520
607
  return () => {
@@ -538,6 +625,7 @@ function useFeedtackState({
538
625
  }, []);
539
626
  const pinMode = usePinMode({
540
627
  hotkey,
628
+ disabled,
541
629
  onDeactivate: () => {
542
630
  resetForm();
543
631
  setOpenThreadId(null);
@@ -667,12 +755,13 @@ function useFeedtackState({
667
755
  handleResolve,
668
756
  handleArchive,
669
757
  isArchivedForUser: (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id),
670
- hasUnread: (item) => item.replies.length > 0
758
+ hasUnread: (item) => item.replies.length > 0,
759
+ hasValidPins: (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0
671
760
  };
672
761
  }
673
762
 
674
763
  // src/react/FeedtackProvider.tsx
675
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
764
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
676
765
  function FeedtackProvider({
677
766
  children,
678
767
  adapter,
@@ -682,30 +771,34 @@ function FeedtackProvider({
682
771
  theme,
683
772
  classes = {},
684
773
  sentimentLabels = {},
685
- onError
774
+ onError,
775
+ disabled = false
686
776
  }) {
687
777
  const state = useFeedtackState({
688
778
  adapter,
689
779
  currentUser,
690
780
  hotkey,
691
781
  theme,
692
- onError
782
+ onError,
783
+ disabled
693
784
  });
694
785
  const firstPin = state.pendingPins[0];
695
786
  const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
696
787
  const showButton = !adminOnly || currentUser.role === "admin";
697
788
  const openItem = state.openThreadId ? state.feedbackItems.find((i) => i.payload.id === state.openThreadId) : null;
698
- return /* @__PURE__ */ jsxs2(
789
+ return /* @__PURE__ */ jsxs3(
699
790
  FeedtackContext.Provider,
700
791
  {
701
792
  value: {
702
- activatePinMode: state.activatePinMode,
703
- deactivatePinMode: state.deactivatePinMode,
704
- isPinModeActive: state.isPinModeActive
793
+ activatePinMode: disabled ? () => {
794
+ } : state.activatePinMode,
795
+ deactivatePinMode: disabled ? () => {
796
+ } : state.deactivatePinMode,
797
+ isPinModeActive: disabled ? false : state.isPinModeActive
705
798
  },
706
799
  children: [
707
800
  children,
708
- showButton && /* @__PURE__ */ jsxs2(
801
+ !disabled && showButton && /* @__PURE__ */ jsxs3(
709
802
  "button",
710
803
  {
711
804
  type: "button",
@@ -723,7 +816,7 @@ function FeedtackProvider({
723
816
  ]
724
817
  }
725
818
  ),
726
- state.isPinModeActive && /* @__PURE__ */ jsx2("div", { className: cx("feedtack-color-picker", classes.colorPicker), children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx2(
819
+ state.isPinModeActive && /* @__PURE__ */ jsx3("div", { className: cx("feedtack-color-picker", classes.colorPicker), children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx3(
727
820
  "button",
728
821
  {
729
822
  type: "button",
@@ -737,7 +830,7 @@ function FeedtackProvider({
737
830
  },
738
831
  color
739
832
  )) }),
740
- state.pendingPins.map((pin) => /* @__PURE__ */ jsx2(
833
+ state.pendingPins.map((pin) => /* @__PURE__ */ jsx3(
741
834
  "div",
742
835
  {
743
836
  className: cx("feedtack-pin-marker", classes.pinMarker),
@@ -750,77 +843,28 @@ function FeedtackProvider({
750
843
  },
751
844
  `${pin.x}-${pin.y}-${pin.color}`
752
845
  )),
753
- state.showForm && /* @__PURE__ */ jsxs2(
754
- "div",
846
+ state.showForm && /* @__PURE__ */ jsx3(
847
+ CommentForm,
755
848
  {
756
- className: cx("feedtack-form", classes.form),
757
- style: { position: "fixed", ...formPos },
758
- children: [
759
- /* @__PURE__ */ jsx2(
760
- "textarea",
761
- {
762
- className: state.commentError ? "error" : "",
763
- placeholder: "What's the issue? (required)",
764
- value: state.comment,
765
- onChange: (e) => {
766
- state.setComment(e.target.value);
767
- state.setCommentError(false);
768
- },
769
- ref: (el) => el?.focus()
770
- }
771
- ),
772
- state.commentError && /* @__PURE__ */ jsx2("span", { className: "feedtack-error-msg", children: "Comment is required" }),
773
- /* @__PURE__ */ jsxs2("div", { className: "feedtack-sentiment", children: [
774
- /* @__PURE__ */ jsx2(
775
- "button",
776
- {
777
- type: "button",
778
- className: state.sentiment === "satisfied" ? "selected" : "",
779
- onClick: () => state.setSentiment(
780
- state.sentiment === "satisfied" ? null : "satisfied"
781
- ),
782
- children: sentimentLabels.satisfied ?? "\u{1F60A} Satisfied"
783
- }
784
- ),
785
- /* @__PURE__ */ jsx2(
786
- "button",
787
- {
788
- type: "button",
789
- className: state.sentiment === "dissatisfied" ? "selected" : "",
790
- onClick: () => state.setSentiment(
791
- state.sentiment === "dissatisfied" ? null : "dissatisfied"
792
- ),
793
- children: sentimentLabels.dissatisfied ?? "\u{1F61E} Dissatisfied"
794
- }
795
- )
796
- ] }),
797
- /* @__PURE__ */ jsxs2("div", { className: "feedtack-form-actions", children: [
798
- /* @__PURE__ */ jsx2(
799
- "button",
800
- {
801
- type: "button",
802
- className: "feedtack-btn-cancel",
803
- onClick: state.deactivatePinMode,
804
- children: "Cancel"
805
- }
806
- ),
807
- /* @__PURE__ */ jsx2(
808
- "button",
809
- {
810
- type: "button",
811
- className: "feedtack-btn-submit",
812
- onClick: state.handleSubmit,
813
- disabled: state.submitting,
814
- children: state.submitting ? "Sending\u2026" : "Submit"
815
- }
816
- )
817
- ] })
818
- ]
849
+ comment: state.comment,
850
+ commentError: state.commentError,
851
+ sentiment: state.sentiment,
852
+ submitting: state.submitting,
853
+ formPos,
854
+ classes,
855
+ sentimentLabels,
856
+ onCommentChange: (v) => {
857
+ state.setComment(v);
858
+ state.setCommentError(false);
859
+ },
860
+ onSentimentChange: state.setSentiment,
861
+ onSubmit: state.handleSubmit,
862
+ onCancel: state.deactivatePinMode
819
863
  }
820
864
  ),
821
- !state.loading && state.feedbackItems.filter((item) => item.payload.page.pathname === state.pathname).filter((item) => !state.isArchivedForUser(item)).map((item) => {
865
+ !state.loading && state.feedbackItems.filter((item) => item.payload.page.pathname === state.pathname).filter((item) => !state.isArchivedForUser(item)).filter((item) => state.hasValidPins(item)).map((item) => {
822
866
  const pin = item.payload.pins[0];
823
- return /* @__PURE__ */ jsx2(
867
+ return /* @__PURE__ */ jsx3(
824
868
  "button",
825
869
  {
826
870
  type: "button",
@@ -835,12 +879,12 @@ function FeedtackProvider({
835
879
  onClick: () => state.setOpenThreadId(
836
880
  state.openThreadId === item.payload.id ? null : item.payload.id
837
881
  ),
838
- children: state.hasUnread(item) && /* @__PURE__ */ jsx2("div", { className: "feedtack-pin-badge" })
882
+ children: state.hasUnread(item) && /* @__PURE__ */ jsx3("div", { className: "feedtack-pin-badge" })
839
883
  },
840
884
  item.payload.id
841
885
  );
842
886
  }),
843
- openItem && /* @__PURE__ */ jsx2(
887
+ openItem && /* @__PURE__ */ jsx3(
844
888
  ThreadPanel,
845
889
  {
846
890
  item: openItem,
@@ -853,7 +897,7 @@ function FeedtackProvider({
853
897
  className: classes.thread
854
898
  }
855
899
  ),
856
- state.loading && /* @__PURE__ */ jsx2("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
900
+ state.loading && /* @__PURE__ */ jsx3("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
857
901
  ]
858
902
  }
859
903
  );
@@ -17,19 +17,36 @@ interface FeedtackBoundingRect {
17
17
  width: number;
18
18
  height: number;
19
19
  }
20
+ interface AncestorNode {
21
+ tag: string;
22
+ id: string | null;
23
+ ariaLabel: string | null;
24
+ role: string | null;
25
+ type: string | null;
26
+ name: string | null;
27
+ title: string | null;
28
+ alt: string | null;
29
+ dataTestId: string | null;
30
+ dataFeedtackComponent: string | null;
31
+ /** 1-indexed position among all sibling elements. Null when node has a stable id or dataTestId. */
32
+ nthChild: number | null;
33
+ /** 1-indexed position among same-tag siblings. Null when node has a stable id or dataTestId. */
34
+ nthOfType: number | null;
35
+ /** React component display name from fiber traversal, or data-feedtack-component value */
36
+ componentName: string | null;
37
+ }
20
38
  interface FeedtackPinTarget {
21
- /** CSS selector path to the clicked element */
39
+ /** CSS selector path to the resolved interactive target */
22
40
  selector: string;
23
41
  /** True when no stable selector was found — downstream consumers should not rely on selector for automated targeting */
24
42
  best_effort: boolean;
25
- /** data-testid attribute value if present, null otherwise — always shipped for downstream consumers */
26
- testId: string | null;
27
- /** Readable DOM ancestry: "div.hero > div.card > button.btn.btn-primary". Walks up to body or nearest data-testid ancestor. Null when element itself has a data-testid (testId is sufficient). */
43
+ /** data-testid attribute value if present, null otherwise */
44
+ dataTestId: string | null;
45
+ /** Readable DOM ancestry path retained for backward compatibility */
28
46
  elementPath: string | null;
29
47
  tagName: string;
30
- /** Trimmed text content of the element, max 200 chars */
31
- textContent: string;
32
- attributes: Record<string, string>;
48
+ /** Ancestor chain up to 5 levels from the resolved target, for LLM element location */
49
+ ancestors: AncestorNode[];
33
50
  boundingRect: FeedtackBoundingRect;
34
51
  }
35
52
  interface FeedtackPin {
@@ -147,4 +164,4 @@ interface FeedtackTheme {
147
164
  /** Maps FeedtackTheme fields to CSS custom properties on #feedtack-root */
148
165
  declare function themeToCSS(theme: FeedtackTheme): Record<string, string>;
149
166
 
150
- export { type FeedtackAdapter as F, SCHEMA_VERSION as S, type FeedtackPayload as a, type FeedtackReply as b, type FeedtackResolution as c, type FeedtackFilter as d, type FeedbackItem as e, type FeedtackDeviceMeta as f, type FeedtackPageMeta as g, type FeedtackViewportMeta as h, type FeedtackPinTarget as i, type FeedtackArchive as j, type FeedtackBoundingRect as k, type FeedtackPin as l, type FeedtackSentiment as m, type FeedtackTheme as n, type FeedtackUser as o, themeToCSS as t };
167
+ export { type AncestorNode as A, type FeedtackAdapter as F, SCHEMA_VERSION as S, type FeedtackPayload as a, type FeedtackReply as b, type FeedtackResolution as c, type FeedtackFilter as d, type FeedbackItem as e, type FeedtackDeviceMeta as f, type FeedtackPageMeta as g, type FeedtackViewportMeta as h, type FeedtackPinTarget as i, type FeedtackArchive as j, type FeedtackBoundingRect as k, type FeedtackPin as l, type FeedtackSentiment as m, type FeedtackTheme as n, type FeedtackUser as o, themeToCSS as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedtack",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Click anywhere. Drop a pin. Get a payload a developer can act on.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "test:watch": "vitest",
31
31
  "lint": "biome check src/",
32
32
  "lint:fix": "biome check --fix src/",
33
- "release": "release-it",
33
+ "release": "release-it --ci",
34
+ "openspec:archive": "openspec archive -y",
34
35
  "prepublishOnly": "pnpm test && pnpm build",
35
36
  "prepare": "husky"
36
37
  },
@@ -1,139 +0,0 @@
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/target.ts
39
- function getElementPath(element) {
40
- const parts = [];
41
- let current = element;
42
- while (current && current !== document.body) {
43
- const tag = current.tagName.toLowerCase();
44
- const classes = Array.from(current.classList).join(".");
45
- let part = classes ? `${tag}.${classes}` : tag;
46
- if (current !== element && current.hasAttribute("data-testid")) {
47
- part = `[data-testid="${current.getAttribute("data-testid")}"]`;
48
- parts.unshift(part);
49
- break;
50
- }
51
- parts.unshift(part);
52
- current = current.parentElement;
53
- }
54
- return parts.join(" > ");
55
- }
56
- function getCSSSelector(element) {
57
- const parts = [];
58
- let current = element;
59
- while (current && current !== document.body) {
60
- let selector = current.tagName.toLowerCase();
61
- const parent = current.parentElement;
62
- if (parent) {
63
- const siblings = Array.from(parent.children).filter(
64
- (c) => c.tagName === current.tagName
65
- );
66
- if (siblings.length > 1) {
67
- const index = siblings.indexOf(current) + 1;
68
- selector += `:nth-of-type(${index})`;
69
- }
70
- }
71
- parts.unshift(selector);
72
- current = current.parentElement;
73
- }
74
- return parts.join(" > ");
75
- }
76
- function getTargetMeta(element) {
77
- const id = element.getAttribute("id");
78
- const testId = element.getAttribute("data-testid");
79
- let selector;
80
- let best_effort;
81
- if (id) {
82
- selector = `#${id}`;
83
- best_effort = false;
84
- } else if (testId) {
85
- selector = `[data-testid="${testId}"]`;
86
- best_effort = false;
87
- } else {
88
- selector = getCSSSelector(element);
89
- best_effort = true;
90
- }
91
- const rect = element.getBoundingClientRect();
92
- const attrs = {};
93
- for (const attr of Array.from(element.attributes)) {
94
- attrs[attr.name] = attr.value;
95
- }
96
- return {
97
- selector,
98
- best_effort,
99
- testId: testId ?? null,
100
- elementPath: testId ? null : getElementPath(element),
101
- tagName: element.tagName,
102
- textContent: (element.textContent ?? "").trim().slice(0, 200),
103
- attributes: attrs,
104
- boundingRect: {
105
- x: rect.x,
106
- y: rect.y,
107
- width: rect.width,
108
- height: rect.height
109
- }
110
- };
111
- }
112
-
113
- // src/types/payload.ts
114
- var SCHEMA_VERSION = "1.0.0";
115
-
116
- // src/types/theme.ts
117
- function themeToCSS(theme) {
118
- const map = {};
119
- if (theme.primary) map["--ft-primary"] = theme.primary;
120
- if (theme.background) map["--ft-bg"] = theme.background;
121
- if (theme.surface) map["--ft-surface"] = theme.surface;
122
- if (theme.text) map["--ft-text"] = theme.text;
123
- if (theme.textMuted) map["--ft-text-muted"] = theme.textMuted;
124
- if (theme.border) map["--ft-border"] = theme.border;
125
- if (theme.radius) map["--ft-radius"] = theme.radius;
126
- if (theme.badge) map["--ft-badge"] = theme.badge;
127
- return map;
128
- }
129
-
130
- export {
131
- getViewportMeta,
132
- getPageMeta,
133
- getDeviceMeta,
134
- getPinCoords,
135
- getCSSSelector,
136
- getTargetMeta,
137
- SCHEMA_VERSION,
138
- themeToCSS
139
- };