feedtack 0.2.0 → 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;
@@ -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 = [
@@ -147,7 +147,8 @@ function ThreadPanel({
147
147
  onClose,
148
148
  className
149
149
  }) {
150
- const pin = item.payload.pins[0];
150
+ const pin = item.payload?.pins?.[0];
151
+ if (!pin) return null;
151
152
  const pos = getAnchoredPosition(pin.x, pin.y);
152
153
  return /* @__PURE__ */ jsxs2(
153
154
  "div",
@@ -596,11 +597,11 @@ function useFeedtackState({
596
597
  const origReplace = history.replaceState.bind(history);
597
598
  history.pushState = (...args) => {
598
599
  origPush(...args);
599
- update();
600
+ queueMicrotask(update);
600
601
  };
601
602
  history.replaceState = (...args) => {
602
603
  origReplace(...args);
603
- update();
604
+ queueMicrotask(update);
604
605
  };
605
606
  window.addEventListener("popstate", update);
606
607
  return () => {
@@ -754,7 +755,8 @@ function useFeedtackState({
754
755
  handleResolve,
755
756
  handleArchive,
756
757
  isArchivedForUser: (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id),
757
- 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
758
760
  };
759
761
  }
760
762
 
@@ -860,7 +862,7 @@ function FeedtackProvider({
860
862
  onCancel: state.deactivatePinMode
861
863
  }
862
864
  ),
863
- !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) => {
864
866
  const pin = item.payload.pins[0];
865
867
  return /* @__PURE__ */ jsx3(
866
868
  "button",
@@ -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.2.0",
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",
@@ -31,6 +31,7 @@
31
31
  "lint": "biome check src/",
32
32
  "lint:fix": "biome check --fix src/",
33
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
- };