feedtack 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trillium Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # feedtack
2
+
3
+ > Click anywhere. Drop a pin. Get a payload a developer can act on.
4
+
5
+ **feedtack** is a drop-in React feedback overlay. Non-technical stakeholders click anywhere on a page, leave a comment, and feedtack emits a structured JSON payload so complete that an LLM can attempt a first-pass fix before consuming developer hours.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install feedtack
11
+ # or
12
+ pnpm add feedtack
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```tsx
18
+ import { FeedtackProvider } from 'feedtack/react'
19
+ import { ConsoleAdapter } from 'feedtack'
20
+
21
+ export default function App() {
22
+ return (
23
+ <FeedtackProvider
24
+ adapter={new ConsoleAdapter()}
25
+ currentUser={{ id: 'u1', name: 'Trillium', role: 'admin' }}
26
+ >
27
+ <YourApp />
28
+ </FeedtackProvider>
29
+ )
30
+ }
31
+ ```
32
+
33
+ ## Production — webhook adapter
34
+
35
+ ```tsx
36
+ import { FeedtackProvider } from 'feedtack/react'
37
+ import { WebhookAdapter } from 'feedtack'
38
+
39
+ const adapter = new WebhookAdapter({
40
+ submitUrl: 'https://your-app.com/api/feedtack',
41
+ updateUrl: 'https://your-app.com/api/feedtack/update', // optional
42
+ loadFeedback: async (filter) => {
43
+ const res = await fetch(`/api/feedtack?pathname=${filter?.pathname ?? ''}`)
44
+ return res.json()
45
+ },
46
+ })
47
+
48
+ export default function App() {
49
+ return (
50
+ <FeedtackProvider
51
+ adapter={adapter}
52
+ currentUser={{ id: 'u1', name: 'Alice', role: 'designer', avatarUrl: '/alice.jpg' }}
53
+ hotkey="p" // default: Shift+P
54
+ adminOnly // only show button to users with role === 'admin'
55
+ onError={console.error}
56
+ >
57
+ <YourApp />
58
+ </FeedtackProvider>
59
+ )
60
+ }
61
+ ```
62
+
63
+ ## Custom adapter
64
+
65
+ ```ts
66
+ import type { FeedtackAdapter } from 'feedtack'
67
+
68
+ class MySupabaseAdapter implements FeedtackAdapter {
69
+ async submit(payload) { /* POST to supabase */ }
70
+ async reply(feedbackId, reply) { /* insert reply */ }
71
+ async resolve(feedbackId, resolution) { /* update resolved */ }
72
+ async archive(feedbackId, userId) { /* insert archive record */ }
73
+ async loadFeedback(filter) { /* select from supabase */ }
74
+ }
75
+ ```
76
+
77
+ ## The payload
78
+
79
+ Every pin emits a versioned JSON payload:
80
+
81
+ ```json
82
+ {
83
+ "schemaVersion": "1.0.0",
84
+ "id": "ft_01j...",
85
+ "timestamp": "2026-04-09T13:42:00.000Z",
86
+ "submittedBy": { "id": "u1", "name": "Alice", "role": "designer" },
87
+ "comment": "This button doesn't do anything",
88
+ "sentiment": "dissatisfied",
89
+ "pins": [{
90
+ "index": 1,
91
+ "color": "#ef4444",
92
+ "x": 420, "y": 812,
93
+ "xPct": 29.2, "yPct": 78.4,
94
+ "target": {
95
+ "selector": "#submit-btn",
96
+ "best_effort": false,
97
+ "tagName": "BUTTON",
98
+ "textContent": "Place Order",
99
+ "attributes": { "id": "submit-btn", "disabled": "true" },
100
+ "boundingRect": { "x": 420, "y": 812, "width": 200, "height": 44 }
101
+ }
102
+ }],
103
+ "page": { "url": "https://app.example.com/checkout", "pathname": "/checkout", "title": "Checkout" },
104
+ "viewport": { "width": 1440, "height": 900, "scrollX": 0, "scrollY": 812, "devicePixelRatio": 2 },
105
+ "device": { "userAgent": "Mozilla/5.0...", "platform": "MacIntel", "touchEnabled": false }
106
+ }
107
+ ```
108
+
109
+ ## `useFeedtack` hook
110
+
111
+ ```tsx
112
+ import { useFeedtack } from 'feedtack/react'
113
+
114
+ function MyButton() {
115
+ const { activatePinMode, isPinModeActive } = useFeedtack()
116
+ return <button onClick={activatePinMode}>{isPinModeActive ? 'Cancel' : 'Give Feedback'}</button>
117
+ }
118
+ ```
119
+
120
+ ## What feedtack does NOT do
121
+
122
+ - LLM triage or routing (downstream concern — feedtack emits, others act)
123
+ - Developer dashboard or inbox
124
+ - Screenshot annotation
125
+
126
+ ## ICEBOX
127
+
128
+ - Script tag CDN distribution
129
+ - Next.js plugin
130
+ - `allowedCaptures` config for scoping DOM access
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,125 @@
1
+ declare const SCHEMA_VERSION = "1.0.0";
2
+ interface FeedtackUser {
3
+ /** Unique identifier — used for attribution across pins, replies, resolutions, archives */
4
+ id: string;
5
+ /** Display name shown in threads */
6
+ name: string;
7
+ /** Controls UI access, e.g. 'admin' | 'designer' | 'stakeholder' | 'partner' */
8
+ role: string;
9
+ /** Shown in reply threads next to name */
10
+ avatarUrl?: string;
11
+ /** Reserved for future notification use */
12
+ email?: string;
13
+ }
14
+ interface FeedtackBoundingRect {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+ interface FeedtackPinTarget {
21
+ /** CSS selector path to the clicked element */
22
+ selector: string;
23
+ /** True when no stable selector was found — downstream consumers should not rely on selector for automated targeting */
24
+ best_effort: boolean;
25
+ tagName: string;
26
+ /** Trimmed text content of the element, max 200 chars */
27
+ textContent: string;
28
+ attributes: Record<string, string>;
29
+ boundingRect: FeedtackBoundingRect;
30
+ }
31
+ interface FeedtackPin {
32
+ /** 1-based index within the session */
33
+ index: number;
34
+ /** Hex color chosen by user from the fixed palette */
35
+ color: string;
36
+ /** Document-relative X coordinate (clientX + scrollX) */
37
+ x: number;
38
+ /** Document-relative Y coordinate (clientY + scrollY) */
39
+ y: number;
40
+ /** X as percentage of document width */
41
+ xPct: number;
42
+ /** Y as percentage of document height */
43
+ yPct: number;
44
+ target: FeedtackPinTarget;
45
+ }
46
+ interface FeedtackPageMeta {
47
+ url: string;
48
+ pathname: string;
49
+ title: string;
50
+ }
51
+ interface FeedtackViewportMeta {
52
+ width: number;
53
+ height: number;
54
+ scrollX: number;
55
+ scrollY: number;
56
+ devicePixelRatio: number;
57
+ }
58
+ interface FeedtackDeviceMeta {
59
+ userAgent: string;
60
+ platform: string;
61
+ touchEnabled: boolean;
62
+ }
63
+ type FeedtackSentiment = 'satisfied' | 'dissatisfied' | null;
64
+ interface FeedtackPayload {
65
+ schemaVersion: string;
66
+ /** Unique feedback ID, e.g. ft_01j... */
67
+ id: string;
68
+ /** ISO 8601 UTC */
69
+ timestamp: string;
70
+ submittedBy: FeedtackUser;
71
+ comment: string;
72
+ sentiment: FeedtackSentiment;
73
+ /** At least one pin required */
74
+ pins: FeedtackPin[];
75
+ page: FeedtackPageMeta;
76
+ viewport: FeedtackViewportMeta;
77
+ device: FeedtackDeviceMeta;
78
+ }
79
+ interface FeedtackReply {
80
+ id: string;
81
+ feedbackId: string;
82
+ author: FeedtackUser;
83
+ body: string;
84
+ /** ISO 8601 UTC */
85
+ timestamp: string;
86
+ }
87
+ interface FeedtackResolution {
88
+ feedbackId: string;
89
+ resolvedBy: FeedtackUser;
90
+ /** ISO 8601 UTC */
91
+ timestamp: string;
92
+ }
93
+ interface FeedtackArchive {
94
+ feedbackId: string;
95
+ archivedBy: FeedtackUser;
96
+ /** ISO 8601 UTC */
97
+ timestamp: string;
98
+ }
99
+ interface FeedbackItem {
100
+ payload: FeedtackPayload;
101
+ replies: FeedtackReply[];
102
+ resolutions: FeedtackResolution[];
103
+ archives: FeedtackArchive[];
104
+ }
105
+ interface FeedtackFilter {
106
+ url?: string;
107
+ pathname?: string;
108
+ userId?: string;
109
+ }
110
+
111
+ /** Plugin contract — implement this interface to create a custom feedtack backend */
112
+ interface FeedtackAdapter {
113
+ /** Submit new feedback payload */
114
+ submit(payload: FeedtackPayload): Promise<void>;
115
+ /** Post a reply to an existing feedback item */
116
+ reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
117
+ /** Mark a feedback item as resolved */
118
+ resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
119
+ /** Archive a feedback item for a specific user */
120
+ archive(feedbackId: string, userId: string): Promise<void>;
121
+ /** Load persisted feedback items, optionally filtered */
122
+ loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
123
+ }
124
+
125
+ 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 FeedtackPinTarget as f, type FeedtackDeviceMeta as g, type FeedtackPageMeta as h, type FeedtackViewportMeta as i, type FeedtackArchive as j, type FeedtackBoundingRect as k, type FeedtackPin as l, type FeedtackSentiment as m, type FeedtackUser as n };
@@ -0,0 +1,105 @@
1
+ // src/types/payload.ts
2
+ var SCHEMA_VERSION = "1.0.0";
3
+
4
+ // src/capture/target.ts
5
+ function getCSSSelector(element) {
6
+ const parts = [];
7
+ let current = element;
8
+ while (current && current !== document.body) {
9
+ let selector = current.tagName.toLowerCase();
10
+ const parent = current.parentElement;
11
+ if (parent) {
12
+ const siblings = Array.from(parent.children).filter(
13
+ (c) => c.tagName === current.tagName
14
+ );
15
+ if (siblings.length > 1) {
16
+ const index = siblings.indexOf(current) + 1;
17
+ selector += `:nth-of-type(${index})`;
18
+ }
19
+ }
20
+ parts.unshift(selector);
21
+ current = current.parentElement;
22
+ }
23
+ return parts.join(" > ");
24
+ }
25
+ function getTargetMeta(element) {
26
+ const id = element.getAttribute("id");
27
+ const testId = element.getAttribute("data-testid");
28
+ let selector;
29
+ let best_effort;
30
+ if (id) {
31
+ selector = `#${id}`;
32
+ best_effort = false;
33
+ } else if (testId) {
34
+ selector = `[data-testid="${testId}"]`;
35
+ best_effort = false;
36
+ } else {
37
+ selector = getCSSSelector(element);
38
+ best_effort = true;
39
+ }
40
+ const rect = element.getBoundingClientRect();
41
+ const attrs = {};
42
+ for (const attr of Array.from(element.attributes)) {
43
+ attrs[attr.name] = attr.value;
44
+ }
45
+ return {
46
+ selector,
47
+ best_effort,
48
+ tagName: element.tagName,
49
+ textContent: (element.textContent ?? "").trim().slice(0, 200),
50
+ attributes: attrs,
51
+ boundingRect: {
52
+ x: rect.x,
53
+ y: rect.y,
54
+ width: rect.width,
55
+ height: rect.height
56
+ }
57
+ };
58
+ }
59
+
60
+ // src/capture/meta.ts
61
+ function getViewportMeta() {
62
+ return {
63
+ width: window.innerWidth,
64
+ height: window.innerHeight,
65
+ scrollX: window.scrollX,
66
+ scrollY: window.scrollY,
67
+ devicePixelRatio: window.devicePixelRatio
68
+ };
69
+ }
70
+ function getPageMeta() {
71
+ return {
72
+ url: window.location.href,
73
+ pathname: window.location.pathname,
74
+ title: document.title
75
+ };
76
+ }
77
+ function getDeviceMeta() {
78
+ return {
79
+ userAgent: navigator.userAgent,
80
+ platform: navigator.platform,
81
+ touchEnabled: navigator.maxTouchPoints > 0
82
+ };
83
+ }
84
+ function getPinCoords(event) {
85
+ const x = event.clientX + window.scrollX;
86
+ const y = event.clientY + window.scrollY;
87
+ const docWidth = document.documentElement.scrollWidth;
88
+ const docHeight = document.documentElement.scrollHeight;
89
+ return {
90
+ x,
91
+ y,
92
+ xPct: Number((x / docWidth * 100).toFixed(2)),
93
+ yPct: Number((y / docHeight * 100).toFixed(2))
94
+ };
95
+ }
96
+
97
+ export {
98
+ SCHEMA_VERSION,
99
+ getCSSSelector,
100
+ getTargetMeta,
101
+ getViewportMeta,
102
+ getPageMeta,
103
+ getDeviceMeta,
104
+ getPinCoords
105
+ };
@@ -0,0 +1,48 @@
1
+ import { F as FeedtackAdapter, a as FeedtackPayload, b as FeedtackReply, c as FeedtackResolution, d as FeedtackFilter, e as FeedbackItem, f as FeedtackPinTarget, g as FeedtackDeviceMeta, h as FeedtackPageMeta, i as FeedtackViewportMeta } from './adapter-DvQFXmyi.js';
2
+ export { j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackUser, S as SCHEMA_VERSION } from './adapter-DvQFXmyi.js';
3
+
4
+ /** Development adapter — logs all operations to the browser console */
5
+ declare class ConsoleAdapter implements FeedtackAdapter {
6
+ submit(payload: FeedtackPayload): Promise<void>;
7
+ reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
8
+ resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
9
+ archive(feedbackId: string, userId: string): Promise<void>;
10
+ loadFeedback(_filter?: FeedtackFilter): Promise<FeedbackItem[]>;
11
+ }
12
+
13
+ interface WebhookAdapterConfig {
14
+ /** URL to POST new feedback payloads to */
15
+ submitUrl: string;
16
+ /** URL to POST reply/resolve/archive state updates to */
17
+ updateUrl?: string;
18
+ /** Required: async function that returns persisted feedback items */
19
+ loadFeedback: (filter?: FeedtackFilter) => Promise<FeedbackItem[]>;
20
+ }
21
+ /** Production adapter — POSTs feedback as JSON to a webhook endpoint */
22
+ declare class WebhookAdapter implements FeedtackAdapter {
23
+ private config;
24
+ constructor(config: WebhookAdapterConfig);
25
+ private post;
26
+ submit(payload: FeedtackPayload): Promise<void>;
27
+ reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
28
+ resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
29
+ archive(feedbackId: string, userId: string): Promise<void>;
30
+ loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
31
+ }
32
+
33
+ /** Build shortest unique CSS selector for an element */
34
+ declare function getCSSSelector(element: Element): string;
35
+ /** Capture DOM target metadata at the clicked element */
36
+ declare function getTargetMeta(element: Element): FeedtackPinTarget;
37
+
38
+ declare function getViewportMeta(): FeedtackViewportMeta;
39
+ declare function getPageMeta(): FeedtackPageMeta;
40
+ declare function getDeviceMeta(): FeedtackDeviceMeta;
41
+ declare function getPinCoords(event: MouseEvent): {
42
+ x: number;
43
+ y: number;
44
+ xPct: number;
45
+ yPct: number;
46
+ };
47
+
48
+ export { ConsoleAdapter, FeedbackItem, FeedtackAdapter, FeedtackDeviceMeta, FeedtackFilter, FeedtackPageMeta, FeedtackPayload, FeedtackPinTarget, FeedtackReply, FeedtackResolution, FeedtackViewportMeta, WebhookAdapter, type WebhookAdapterConfig, getCSSSelector, getDeviceMeta, getPageMeta, getPinCoords, getTargetMeta, getViewportMeta };
package/dist/index.js ADDED
@@ -0,0 +1,80 @@
1
+ import {
2
+ SCHEMA_VERSION,
3
+ getCSSSelector,
4
+ getDeviceMeta,
5
+ getPageMeta,
6
+ getPinCoords,
7
+ getTargetMeta,
8
+ getViewportMeta
9
+ } from "./chunk-VSVTP7O5.js";
10
+
11
+ // src/adapters/ConsoleAdapter.ts
12
+ var ConsoleAdapter = class {
13
+ async submit(payload) {
14
+ console.log("[feedtack] submit", payload);
15
+ }
16
+ async reply(feedbackId, reply) {
17
+ console.log("[feedtack] reply", { feedbackId, reply });
18
+ }
19
+ async resolve(feedbackId, resolution) {
20
+ console.log("[feedtack] resolve", { feedbackId, resolution });
21
+ }
22
+ async archive(feedbackId, userId) {
23
+ console.log("[feedtack] archive", { feedbackId, userId });
24
+ }
25
+ async loadFeedback(_filter) {
26
+ console.log("[feedtack] loadFeedback \u2014 ConsoleAdapter returns empty array");
27
+ return [];
28
+ }
29
+ };
30
+
31
+ // src/adapters/WebhookAdapter.ts
32
+ var WebhookAdapter = class {
33
+ constructor(config) {
34
+ this.config = config;
35
+ }
36
+ async post(url, body) {
37
+ let response;
38
+ try {
39
+ response = await fetch(url, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify(body)
43
+ });
44
+ } catch (err) {
45
+ throw new Error(`[feedtack] Network error: ${err.message}`);
46
+ }
47
+ if (!response.ok) {
48
+ throw new Error(`[feedtack] Webhook responded with ${response.status}`);
49
+ }
50
+ }
51
+ async submit(payload) {
52
+ await this.post(this.config.submitUrl, payload);
53
+ }
54
+ async reply(feedbackId, reply) {
55
+ const url = this.config.updateUrl ?? this.config.submitUrl;
56
+ await this.post(url, { type: "reply", feedbackId, ...reply });
57
+ }
58
+ async resolve(feedbackId, resolution) {
59
+ const url = this.config.updateUrl ?? this.config.submitUrl;
60
+ await this.post(url, { type: "resolve", feedbackId, ...resolution });
61
+ }
62
+ async archive(feedbackId, userId) {
63
+ const url = this.config.updateUrl ?? this.config.submitUrl;
64
+ await this.post(url, { type: "archive", feedbackId, userId });
65
+ }
66
+ async loadFeedback(filter) {
67
+ return this.config.loadFeedback(filter);
68
+ }
69
+ };
70
+ export {
71
+ ConsoleAdapter,
72
+ SCHEMA_VERSION,
73
+ WebhookAdapter,
74
+ getCSSSelector,
75
+ getDeviceMeta,
76
+ getPageMeta,
77
+ getPinCoords,
78
+ getTargetMeta,
79
+ getViewportMeta
80
+ };
@@ -0,0 +1,26 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import React from 'react';
3
+ import { F as FeedtackAdapter, n as FeedtackUser } from '../adapter-DvQFXmyi.js';
4
+
5
+ interface FeedtackProviderProps {
6
+ children: React.ReactNode;
7
+ adapter: FeedtackAdapter;
8
+ currentUser: FeedtackUser;
9
+ /** Keyboard shortcut to toggle pin mode. Default: 'p' (Shift+P) */
10
+ hotkey?: string;
11
+ /** Only show the activation button for users whose role is in this list */
12
+ adminOnly?: boolean;
13
+ onError?: (err: Error) => void;
14
+ }
15
+ declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, onError }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
16
+
17
+ interface FeedtackContextValue {
18
+ activatePinMode: () => void;
19
+ deactivatePinMode: () => void;
20
+ isPinModeActive: boolean;
21
+ }
22
+
23
+ /** Hook for host app to programmatically control feedtack */
24
+ declare function useFeedtack(): FeedtackContextValue;
25
+
26
+ export { FeedtackProvider, useFeedtack };
@@ -0,0 +1,557 @@
1
+ import {
2
+ SCHEMA_VERSION,
3
+ getDeviceMeta,
4
+ getPageMeta,
5
+ getPinCoords,
6
+ getTargetMeta,
7
+ getViewportMeta
8
+ } from "../chunk-VSVTP7O5.js";
9
+
10
+ // src/react/FeedtackProvider.tsx
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+
13
+ // src/react/context.ts
14
+ import { createContext, useContext } from "react";
15
+ var FeedtackContext = createContext(null);
16
+ function useFeedtackContext() {
17
+ const ctx = useContext(FeedtackContext);
18
+ if (!ctx) throw new Error("useFeedtack must be used inside <FeedtackProvider>");
19
+ return ctx;
20
+ }
21
+
22
+ // src/ui/styles.ts
23
+ var FEEDTACK_STYLES = `
24
+ #feedtack-root * {
25
+ box-sizing: border-box;
26
+ margin: 0;
27
+ padding: 0;
28
+ font-family: system-ui, -apple-system, sans-serif;
29
+ line-height: 1.5;
30
+ }
31
+
32
+ .feedtack-btn {
33
+ position: fixed;
34
+ bottom: 24px;
35
+ right: 24px;
36
+ z-index: 2147483640;
37
+ background: #1a1a1a;
38
+ color: #fff;
39
+ border: none;
40
+ border-radius: 8px;
41
+ padding: 8px 14px;
42
+ font-size: 13px;
43
+ font-weight: 500;
44
+ cursor: pointer;
45
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 6px;
49
+ transition: background 0.15s;
50
+ }
51
+
52
+ .feedtack-btn:hover {
53
+ background: #333;
54
+ }
55
+
56
+ .feedtack-btn.active {
57
+ background: #2563eb;
58
+ }
59
+
60
+ .feedtack-crosshair * {
61
+ cursor: crosshair !important;
62
+ }
63
+
64
+ .feedtack-pin-marker {
65
+ position: absolute;
66
+ z-index: 2147483641;
67
+ width: 24px;
68
+ height: 24px;
69
+ border-radius: 50% 50% 50% 0;
70
+ transform: rotate(-45deg) translate(-50%, -50%);
71
+ border: 2px solid rgba(255,255,255,0.8);
72
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
73
+ cursor: pointer;
74
+ pointer-events: all;
75
+ }
76
+
77
+ .feedtack-pin-badge {
78
+ position: absolute;
79
+ top: -4px;
80
+ right: -4px;
81
+ width: 10px;
82
+ height: 10px;
83
+ background: #f59e0b;
84
+ border-radius: 50%;
85
+ border: 1.5px solid #fff;
86
+ }
87
+
88
+ .feedtack-color-picker {
89
+ display: flex;
90
+ gap: 6px;
91
+ padding: 8px;
92
+ background: #fff;
93
+ border-radius: 8px;
94
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
95
+ position: fixed;
96
+ bottom: 72px;
97
+ right: 24px;
98
+ z-index: 2147483641;
99
+ }
100
+
101
+ .feedtack-color-swatch {
102
+ width: 20px;
103
+ height: 20px;
104
+ border-radius: 50%;
105
+ border: 2px solid transparent;
106
+ cursor: pointer;
107
+ transition: transform 0.1s;
108
+ }
109
+
110
+ .feedtack-color-swatch.selected {
111
+ border-color: #1a1a1a;
112
+ transform: scale(1.15);
113
+ }
114
+
115
+ .feedtack-form {
116
+ position: absolute;
117
+ z-index: 2147483642;
118
+ background: #fff;
119
+ border-radius: 10px;
120
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
121
+ padding: 16px;
122
+ width: 280px;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 10px;
126
+ }
127
+
128
+ .feedtack-form textarea {
129
+ width: 100%;
130
+ border: 1.5px solid #e5e7eb;
131
+ border-radius: 6px;
132
+ padding: 8px;
133
+ font-size: 13px;
134
+ resize: vertical;
135
+ min-height: 80px;
136
+ outline: none;
137
+ }
138
+
139
+ .feedtack-form textarea:focus {
140
+ border-color: #2563eb;
141
+ }
142
+
143
+ .feedtack-form textarea.error {
144
+ border-color: #ef4444;
145
+ }
146
+
147
+ .feedtack-error-msg {
148
+ font-size: 12px;
149
+ color: #ef4444;
150
+ }
151
+
152
+ .feedtack-sentiment {
153
+ display: flex;
154
+ gap: 8px;
155
+ }
156
+
157
+ .feedtack-sentiment button {
158
+ flex: 1;
159
+ padding: 6px 10px;
160
+ border: 1.5px solid #e5e7eb;
161
+ border-radius: 6px;
162
+ background: #fff;
163
+ font-size: 12px;
164
+ cursor: pointer;
165
+ transition: all 0.1s;
166
+ }
167
+
168
+ .feedtack-sentiment button.selected {
169
+ border-color: #2563eb;
170
+ background: #eff6ff;
171
+ color: #2563eb;
172
+ }
173
+
174
+ .feedtack-form-actions {
175
+ display: flex;
176
+ gap: 8px;
177
+ justify-content: flex-end;
178
+ }
179
+
180
+ .feedtack-btn-cancel {
181
+ padding: 6px 12px;
182
+ border: 1.5px solid #e5e7eb;
183
+ border-radius: 6px;
184
+ background: #fff;
185
+ font-size: 13px;
186
+ cursor: pointer;
187
+ }
188
+
189
+ .feedtack-btn-submit {
190
+ padding: 6px 12px;
191
+ border: none;
192
+ border-radius: 6px;
193
+ background: #2563eb;
194
+ color: #fff;
195
+ font-size: 13px;
196
+ font-weight: 500;
197
+ cursor: pointer;
198
+ }
199
+
200
+ .feedtack-btn-submit:disabled {
201
+ opacity: 0.5;
202
+ cursor: not-allowed;
203
+ }
204
+
205
+ .feedtack-thread {
206
+ position: absolute;
207
+ z-index: 2147483642;
208
+ background: #fff;
209
+ border-radius: 10px;
210
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
211
+ padding: 16px;
212
+ width: 300px;
213
+ max-height: 400px;
214
+ overflow-y: auto;
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 10px;
218
+ }
219
+
220
+ .feedtack-loading {
221
+ position: fixed;
222
+ bottom: 70px;
223
+ right: 24px;
224
+ font-size: 12px;
225
+ color: #6b7280;
226
+ z-index: 2147483640;
227
+ }
228
+ `;
229
+
230
+ // src/ui/colors.ts
231
+ var PIN_PALETTE = [
232
+ "#ef4444",
233
+ // red
234
+ "#3b82f6",
235
+ // blue
236
+ "#22c55e",
237
+ // green
238
+ "#f59e0b",
239
+ // amber
240
+ "#a855f7",
241
+ // purple
242
+ "#ec4899"
243
+ // pink
244
+ ];
245
+
246
+ // src/react/FeedtackProvider.tsx
247
+ import { jsx, jsxs } from "react/jsx-runtime";
248
+ function generateId() {
249
+ return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
250
+ }
251
+ function getAnchoredPosition(x, y) {
252
+ const FORM_WIDTH = 290;
253
+ const FORM_HEIGHT = 220;
254
+ const EDGE = 300;
255
+ const vw = window.innerWidth;
256
+ const vh = window.innerHeight;
257
+ const clientX = x - window.scrollX;
258
+ const clientY = y - window.scrollY;
259
+ const left = clientX > vw - EDGE ? void 0 : clientX + 16;
260
+ const right = clientX > vw - EDGE ? vw - clientX + 16 : void 0;
261
+ const top = clientY > vh - EDGE ? void 0 : clientY + 16;
262
+ const bottom = clientY > vh - EDGE ? vh - clientY + FORM_HEIGHT : void 0;
263
+ return { left, right, top, bottom };
264
+ }
265
+ function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminOnly = false, onError }) {
266
+ const [isPinModeActive, setIsPinModeActive] = useState(false);
267
+ const [pendingPins, setPendingPins] = useState([]);
268
+ const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
269
+ const [showForm, setShowForm] = useState(false);
270
+ const [comment, setComment] = useState("");
271
+ const [sentiment, setSentiment] = useState(null);
272
+ const [commentError, setCommentError] = useState(false);
273
+ const [submitting, setSubmitting] = useState(false);
274
+ const [feedbackItems, setFeedbackItems] = useState([]);
275
+ const [loading, setLoading] = useState(true);
276
+ const [openThreadId, setOpenThreadId] = useState(null);
277
+ const [replyBody, setReplyBody] = useState("");
278
+ const rootRef = useRef(null);
279
+ useEffect(() => {
280
+ if (document.getElementById("feedtack-styles")) return;
281
+ const style = document.createElement("style");
282
+ style.id = "feedtack-styles";
283
+ style.textContent = FEEDTACK_STYLES;
284
+ document.head.appendChild(style);
285
+ return () => {
286
+ style.remove();
287
+ };
288
+ }, []);
289
+ useEffect(() => {
290
+ const root = document.createElement("div");
291
+ root.id = "feedtack-root";
292
+ document.body.appendChild(root);
293
+ rootRef.current = root;
294
+ return () => {
295
+ root.remove();
296
+ };
297
+ }, []);
298
+ useEffect(() => {
299
+ setLoading(true);
300
+ adapter.loadFeedback({ pathname: window.location.pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
301
+ }, [adapter, onError]);
302
+ const activatePinMode = useCallback(() => setIsPinModeActive(true), []);
303
+ const deactivatePinMode = useCallback(() => {
304
+ setIsPinModeActive(false);
305
+ setPendingPins([]);
306
+ setShowForm(false);
307
+ setComment("");
308
+ setSentiment(null);
309
+ setCommentError(false);
310
+ }, []);
311
+ useEffect(() => {
312
+ if (isPinModeActive) {
313
+ document.documentElement.classList.add("feedtack-crosshair");
314
+ } else {
315
+ document.documentElement.classList.remove("feedtack-crosshair");
316
+ }
317
+ return () => document.documentElement.classList.remove("feedtack-crosshair");
318
+ }, [isPinModeActive]);
319
+ useEffect(() => {
320
+ const handler = (e) => {
321
+ if (e.key === hotkey.toUpperCase() && e.shiftKey) {
322
+ setIsPinModeActive((prev) => !prev);
323
+ }
324
+ if (e.key === "Escape") {
325
+ deactivatePinMode();
326
+ setOpenThreadId(null);
327
+ }
328
+ };
329
+ window.addEventListener("keydown", handler);
330
+ return () => window.removeEventListener("keydown", handler);
331
+ }, [hotkey, deactivatePinMode]);
332
+ const handlePageClick = useCallback((e) => {
333
+ if (!isPinModeActive) return;
334
+ const target = e.target;
335
+ if (target.closest("#feedtack-root") || target.closest(".feedtack-form") || target.closest(".feedtack-color-picker")) return;
336
+ e.preventDefault();
337
+ e.stopPropagation();
338
+ const coords = getPinCoords(e);
339
+ const targetMeta = getTargetMeta(target);
340
+ setPendingPins((prev) => [...prev, {
341
+ color: selectedColor,
342
+ ...coords,
343
+ target: targetMeta
344
+ }]);
345
+ setShowForm(true);
346
+ }, [isPinModeActive, selectedColor]);
347
+ useEffect(() => {
348
+ document.addEventListener("click", handlePageClick, true);
349
+ return () => document.removeEventListener("click", handlePageClick, true);
350
+ }, [handlePageClick]);
351
+ const handleSubmit = async () => {
352
+ if (!comment.trim()) {
353
+ setCommentError(true);
354
+ return;
355
+ }
356
+ setSubmitting(true);
357
+ const payload = {
358
+ schemaVersion: SCHEMA_VERSION,
359
+ id: generateId(),
360
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
361
+ submittedBy: currentUser,
362
+ comment: comment.trim(),
363
+ sentiment,
364
+ pins: pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
365
+ page: getPageMeta(),
366
+ viewport: getViewportMeta(),
367
+ device: getDeviceMeta()
368
+ };
369
+ try {
370
+ await adapter.submit(payload);
371
+ setFeedbackItems((prev) => [...prev, { payload, replies: [], resolutions: [], archives: [] }]);
372
+ deactivatePinMode();
373
+ } catch (err) {
374
+ onError?.(err);
375
+ } finally {
376
+ setSubmitting(false);
377
+ }
378
+ };
379
+ const handleReply = async (feedbackId) => {
380
+ if (!replyBody.trim()) return;
381
+ try {
382
+ await adapter.reply(feedbackId, {
383
+ author: currentUser,
384
+ body: replyBody.trim(),
385
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
386
+ });
387
+ setFeedbackItems((prev) => prev.map(
388
+ (item) => item.payload.id === feedbackId ? { ...item, replies: [...item.replies, { id: generateId(), feedbackId, author: currentUser, body: replyBody.trim(), timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
389
+ ));
390
+ setReplyBody("");
391
+ } catch (err) {
392
+ onError?.(err);
393
+ }
394
+ };
395
+ const handleResolve = async (feedbackId) => {
396
+ try {
397
+ await adapter.resolve(feedbackId, { resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
398
+ setFeedbackItems((prev) => prev.map(
399
+ (item) => item.payload.id === feedbackId ? { ...item, resolutions: [...item.resolutions, { feedbackId, resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
400
+ ));
401
+ } catch (err) {
402
+ onError?.(err);
403
+ }
404
+ };
405
+ const handleArchive = async (feedbackId) => {
406
+ try {
407
+ await adapter.archive(feedbackId, currentUser.id);
408
+ setFeedbackItems((prev) => prev.map(
409
+ (item) => item.payload.id === feedbackId ? { ...item, archives: [...item.archives, { feedbackId, archivedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
410
+ ));
411
+ setOpenThreadId(null);
412
+ } catch (err) {
413
+ onError?.(err);
414
+ }
415
+ };
416
+ const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
417
+ const hasUnread = (item) => item.replies.length > 0;
418
+ const firstPin = pendingPins[0];
419
+ const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
420
+ const showButton = !adminOnly || currentUser.role === "admin";
421
+ return /* @__PURE__ */ jsxs(FeedtackContext.Provider, { value: { activatePinMode, deactivatePinMode, isPinModeActive }, children: [
422
+ children,
423
+ showButton && /* @__PURE__ */ jsxs(
424
+ "button",
425
+ {
426
+ className: `feedtack-btn${isPinModeActive ? " active" : ""}`,
427
+ onClick: () => isPinModeActive ? deactivatePinMode() : activatePinMode(),
428
+ title: "Toggle feedback pin mode",
429
+ children: [
430
+ "Drop Pin [Shift+",
431
+ hotkey.toUpperCase(),
432
+ "]"
433
+ ]
434
+ }
435
+ ),
436
+ isPinModeActive && /* @__PURE__ */ jsx("div", { className: "feedtack-color-picker", children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx(
437
+ "button",
438
+ {
439
+ className: `feedtack-color-swatch${selectedColor === color ? " selected" : ""}`,
440
+ style: { background: color },
441
+ onClick: () => setSelectedColor(color),
442
+ title: color
443
+ },
444
+ color
445
+ )) }),
446
+ pendingPins.map((pin, i) => /* @__PURE__ */ jsx(
447
+ "div",
448
+ {
449
+ className: "feedtack-pin-marker",
450
+ style: {
451
+ background: pin.color,
452
+ left: pin.x,
453
+ top: pin.y,
454
+ position: "absolute"
455
+ }
456
+ },
457
+ i
458
+ )),
459
+ showForm && /* @__PURE__ */ jsxs("div", { className: "feedtack-form", style: { position: "fixed", ...formPos }, children: [
460
+ /* @__PURE__ */ jsx(
461
+ "textarea",
462
+ {
463
+ className: commentError ? "error" : "",
464
+ placeholder: "What's the issue? (required)",
465
+ value: comment,
466
+ onChange: (e) => {
467
+ setComment(e.target.value);
468
+ setCommentError(false);
469
+ },
470
+ autoFocus: true
471
+ }
472
+ ),
473
+ commentError && /* @__PURE__ */ jsx("span", { className: "feedtack-error-msg", children: "Comment is required" }),
474
+ /* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
475
+ /* @__PURE__ */ jsx(
476
+ "button",
477
+ {
478
+ className: sentiment === "satisfied" ? "selected" : "",
479
+ onClick: () => setSentiment(sentiment === "satisfied" ? null : "satisfied"),
480
+ children: "\u{1F60A} Satisfied"
481
+ }
482
+ ),
483
+ /* @__PURE__ */ jsx(
484
+ "button",
485
+ {
486
+ className: sentiment === "dissatisfied" ? "selected" : "",
487
+ onClick: () => setSentiment(sentiment === "dissatisfied" ? null : "dissatisfied"),
488
+ children: "\u{1F61E} Dissatisfied"
489
+ }
490
+ )
491
+ ] }),
492
+ /* @__PURE__ */ jsxs("div", { className: "feedtack-form-actions", children: [
493
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", onClick: deactivatePinMode, children: "Cancel" }),
494
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", onClick: handleSubmit, disabled: submitting, children: submitting ? "Sending\u2026" : "Submit" })
495
+ ] })
496
+ ] }),
497
+ !loading && feedbackItems.filter((item) => !isArchivedForUser(item)).map((item) => {
498
+ const firstItemPin = item.payload.pins[0];
499
+ const unread = hasUnread(item);
500
+ return /* @__PURE__ */ jsx(
501
+ "div",
502
+ {
503
+ className: "feedtack-pin-marker",
504
+ style: {
505
+ background: firstItemPin.color,
506
+ left: firstItemPin.x,
507
+ top: firstItemPin.y,
508
+ position: "absolute",
509
+ cursor: "pointer"
510
+ },
511
+ onClick: () => setOpenThreadId(openThreadId === item.payload.id ? null : item.payload.id),
512
+ children: unread && /* @__PURE__ */ jsx("div", { className: "feedtack-pin-badge" })
513
+ },
514
+ item.payload.id
515
+ );
516
+ }),
517
+ openThreadId && (() => {
518
+ const item = feedbackItems.find((i) => i.payload.id === openThreadId);
519
+ if (!item) return null;
520
+ const pin = item.payload.pins[0];
521
+ const pos = getAnchoredPosition(pin.x, pin.y);
522
+ return /* @__PURE__ */ jsxs("div", { className: "feedtack-thread", style: { position: "fixed", ...pos }, children: [
523
+ /* @__PURE__ */ jsx("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
524
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 13 }, children: item.payload.comment }),
525
+ item.replies.map((r) => /* @__PURE__ */ jsxs("div", { style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 }, children: [
526
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
527
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 12 }, children: r.body })
528
+ ] }, r.id)),
529
+ /* @__PURE__ */ jsx(
530
+ "textarea",
531
+ {
532
+ placeholder: "Reply\u2026",
533
+ value: replyBody,
534
+ onChange: (e) => setReplyBody(e.target.value),
535
+ style: { width: "100%", fontSize: 12, padding: 6, borderRadius: 6, border: "1px solid #e5e7eb", marginTop: 4 }
536
+ }
537
+ ),
538
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
539
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", style: { fontSize: 12, padding: "4px 10px" }, onClick: () => handleReply(openThreadId), children: "Reply" }),
540
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleResolve(openThreadId), children: "Mark Resolved" }),
541
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleArchive(openThreadId), children: "Archive" }),
542
+ /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => setOpenThreadId(null), children: "Close" })
543
+ ] })
544
+ ] });
545
+ })(),
546
+ loading && /* @__PURE__ */ jsx("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
547
+ ] });
548
+ }
549
+
550
+ // src/react/useFeedtack.ts
551
+ function useFeedtack() {
552
+ return useFeedtackContext();
553
+ }
554
+ export {
555
+ FeedtackProvider,
556
+ useFeedtack
557
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "feedtack",
3
+ "version": "0.0.1",
4
+ "description": "Click anywhere. Drop a pin. Get a payload a developer can act on.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Trillium Smith",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/trillium/feedtack"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react/index.d.ts",
19
+ "default": "./dist/react/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "packageManager": "pnpm@9.0.0",
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "dev": "tsup --watch",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "lint": "eslint src/",
32
+ "prepublishOnly": "pnpm test && pnpm build"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=18.0.0",
36
+ "react-dom": ">=18.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@testing-library/jest-dom": "^6.4.0",
40
+ "@testing-library/react": "^16.0.0",
41
+ "@types/node": "^22.0.0",
42
+ "@types/react": "^18.3.0",
43
+ "@types/react-dom": "^18.3.0",
44
+ "@vitejs/plugin-react": "^4.3.0",
45
+ "eslint": "^9.0.0",
46
+ "jsdom": "^25.0.0",
47
+ "tsup": "^8.5.1",
48
+ "typescript": "^5.5.0",
49
+ "typescript-eslint": "^8.0.0",
50
+ "vitest": "^2.0.0"
51
+ }
52
+ }