@synod-ai/extension-feedback 0.1.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/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # @synod-ai/extension-feedback
2
+
3
+ Synod Feedback Extension SDK — submit in-app feedback as structured findings to a Synod gateway instance. Captures element context (CSS selector, viewport, route), optional screenshots, and queues findings for batch submission with retry.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @synod-ai/extension-feedback
9
+ ```
10
+
11
+ ### Peer Dependencies
12
+
13
+ - **React** ≥18 and **react-dom** ≥18 (only if using the React wrapper)
14
+ - The core SDK is framework-agnostic — zero React imports
15
+
16
+ ---
17
+
18
+ ## Quick Start
19
+
20
+ ### Vanilla JavaScript
21
+
22
+ ```js
23
+ import { init, enable, queue, sendBatch } from '@synod-ai/extension-feedback';
24
+
25
+ // 1. Configure with your gateway URL, project ID, and extension token
26
+ init({
27
+ gatewayUrl: 'http://localhost:8787',
28
+ projectId: 'your-project-id',
29
+ token: 'your-extension-token',
30
+ });
31
+
32
+ // 2. Enable the feedback overlay (key-combo listener activates)
33
+ enable();
34
+
35
+ // 3. Queue a finding manually (or use the built-in element picker via Ctrl+Shift+E)
36
+ queue({
37
+ title: 'Button contrast too low',
38
+ description: 'The checkout button fails WCAG AA contrast at 2.4:1',
39
+ severity: 'high',
40
+ context_json: {
41
+ url: 'https://shop.example.com/checkout',
42
+ css_selector: '#checkout-btn',
43
+ element_text: 'Pay Now',
44
+ viewport: { width: 1440, height: 900 },
45
+ route: '/checkout',
46
+ },
47
+ });
48
+
49
+ // 4. Submit batch to the gateway
50
+ const result = await sendBatch();
51
+ console.log(`Created: ${result.created.length}, Errors: ${result.errors.length}`);
52
+ ```
53
+
54
+ ### React
55
+
56
+ ```jsx
57
+ import { FeedbackProvider, useFeedback } from '@synod-ai/extension-feedback/react';
58
+
59
+ // Wrap your app
60
+ function App() {
61
+ return (
62
+ <FeedbackProvider
63
+ config={{
64
+ gatewayUrl: 'http://localhost:8787',
65
+ projectId: 'your-project-id',
66
+ token: 'your-extension-token',
67
+ keyCombo: 'Ctrl+Shift+E',
68
+ }}
69
+ autoEnable
70
+ >
71
+ <YourApp />
72
+ </FeedbackProvider>
73
+ );
74
+ }
75
+
76
+ // Use the hook in any child component
77
+ function FeedbackButton() {
78
+ const { isEnabled, enable, disable, sendBatch } = useFeedback();
79
+
80
+ return (
81
+ <button onClick={() => (isEnabled ? disable() : enable())}>
82
+ {isEnabled ? 'Disable Feedback' : 'Enable Feedback'}
83
+ </button>
84
+ );
85
+ }
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Configuration
91
+
92
+ ### `FeedbackConfig`
93
+
94
+ | Field | Type | Required | Default | Description |
95
+ |---|---|---|---|---|
96
+ | `gatewayUrl` | `string` | Yes (defaults if omitted) | `http://localhost:8787` | Synod gateway base URL |
97
+ | `projectId` | `string` | **Yes** | — | Target project ID |
98
+ | `token` | `string` | **Yes** | — | Extension auth token (generated in Desktop → Extensions settings tab) |
99
+ | `keyCombo` | `string` | No | `Ctrl+Shift+E` | Key combo to activate the feedback overlay (hold ~500ms) |
100
+ | `timeout` | `number` | No | `15000` | Request timeout in ms |
101
+ | `maxRetries` | `number` | No | `3` | Max retry attempts on transient failures |
102
+
103
+ ### Key Combo Format
104
+
105
+ The `keyCombo` string uses `+`-separated modifiers + key:
106
+
107
+ ```
108
+ Ctrl+Shift+E (default — avoids Ctrl+Shift+F browser fullscreen conflict)
109
+ Ctrl+Alt+F
110
+ Shift+Q
111
+ ```
112
+
113
+ Supported modifiers: `Ctrl` (or `Control`), `Shift`, `Alt`.
114
+
115
+ Hold detection: the combo must be held for ~500ms to activate, preventing accidental triggers.
116
+
117
+ ---
118
+
119
+ ## API Reference
120
+
121
+ ### Core Functions (`@synod-ai/extension-feedback`)
122
+
123
+ | Function | Description |
124
+ |---|---|
125
+ | `init(config)` | Initialize the SDK with gateway config |
126
+ | `enable()` | Enable the feedback overlay + key-combo listener |
127
+ | `disable()` | Disable the feedback overlay |
128
+ | `isEnabled()` | Returns `true` if the overlay is active |
129
+ | `queue(finding)` | Add a finding to the submission queue |
130
+ | `sendBatch()` | Submit all queued findings to the gateway (returns `Promise<BatchResult>`) |
131
+ | `clearQueue()` | Remove all findings from the queue |
132
+ | `getQueueLength()` | Current queue size |
133
+
134
+ ### React (`@synod-ai/extension-feedback/react`)
135
+
136
+ | Export | Description |
137
+ |---|---|
138
+ | `<FeedbackProvider config={...} autoEnable?>` | Context provider that manages the FeedbackManager lifecycle |
139
+ | `useFeedback()` | Hook returning `{ init, enable, disable, sendBatch, queue, isEnabled, enabled }` |
140
+
141
+ ### Types
142
+
143
+ ```typescript
144
+ type FindingSeverity = 'low' | 'medium' | 'high';
145
+
146
+ interface FindingContextJson {
147
+ url?: string;
148
+ css_selector?: string;
149
+ element_text?: string;
150
+ viewport?: { width: number; height: number };
151
+ route?: string;
152
+ screenshot_data_url?: string; // data:image/* URL, ≤10KB total context budget
153
+ }
154
+
155
+ interface FindingPayload {
156
+ title: string;
157
+ description: string;
158
+ severity: FindingSeverity;
159
+ suggested_action?: 'create_story';
160
+ context_json: FindingContextJson;
161
+ }
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Gateway Setup
167
+
168
+ ### 1. Token Generation
169
+
170
+ Generate an extension token from the Synod Desktop app:
171
+
172
+ 1. Open **Settings → Extensions** tab
173
+ 2. Click **Generate Token**
174
+ 3. Copy the token and pass it to `init({ token })`
175
+
176
+ ### 2. Project ID
177
+
178
+ Find your project ID in the Synod Desktop sidebar or via `GET /v2/projects/active`.
179
+
180
+ ---
181
+
182
+ ## Network Topology
183
+
184
+ The extension SDK communicates with the Synod gateway via HTTP `POST /v2/findings/external`. The gateway URL determines how the extension connects.
185
+
186
+ ### Localhost (Default)
187
+
188
+ ```
189
+ Browser (your app) → http://localhost:8787 → Synod Gateway
190
+ ```
191
+
192
+ - **Default mode** — works for same-machine testing
193
+ - Chrome and Firefox have a **localhost mixed-content exemption**: `fetch()` from an HTTPS page to `http://localhost:*` is allowed
194
+ - No additional setup needed
195
+ - Gateway URL: `http://localhost:8787`
196
+
197
+ ### LAN (Same Network)
198
+
199
+ ```
200
+ Browser (other device) → http://192.168.x.x:8787 → Synod Gateway (0.0.0.0 binding)
201
+ ```
202
+
203
+ - For testing from other devices on the same local network
204
+ - Bind the gateway to `0.0.0.0` to accept LAN connections
205
+ - Gateway URL: `http://<your-lan-ip>:8787`
206
+ - **Note:** Only works if your app is served over HTTP. If your app is HTTPS, see Tunnel mode below.
207
+
208
+ ### Tunnel (HTTPS Apps / Remote Access)
209
+
210
+ ```
211
+ Browser (HTTPS app) → https://your-tunnel.ngrok.io → Synod Gateway
212
+ ```
213
+
214
+ **When to use:** Your web app is served over HTTPS and needs to call the gateway. Browsers block `fetch()` from HTTPS pages to HTTP endpoints (**mixed content blocking**). The localhost exemption does not apply to LAN IPs — only to `localhost` / `127.0.0.1`.
215
+
216
+ **Setup with ngrok:**
217
+
218
+ ```bash
219
+ # Expose the Synod gateway (default port 8787) via HTTPS
220
+ ngrok http 8787
221
+
222
+ # Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io)
223
+ # Use it as the gatewayUrl:
224
+ ```
225
+
226
+ ```js
227
+ init({
228
+ gatewayUrl: 'https://abc123.ngrok.io',
229
+ projectId: 'your-project-id',
230
+ token: 'your-extension-token',
231
+ });
232
+ ```
233
+
234
+ **Setup with Cloudflare Tunnel:**
235
+
236
+ ```bash
237
+ cloudflared tunnel --url http://localhost:8787
238
+ # Copy the generated HTTPS URL
239
+ ```
240
+
241
+ ### Mixed Content Guidance
242
+
243
+ | App URL | Gateway URL | Works? | Notes |
244
+ |---|---|---|---|
245
+ | `http://localhost:3000` | `http://localhost:8787` | ✅ | Same-origin HTTP, no issue |
246
+ | `https://localhost:3000` | `http://localhost:8787` | ✅ | Localhost mixed-content exemption |
247
+ | `https://app.example.com` | `http://localhost:8787` | ❌ | Mixed content blocked — use tunnel |
248
+ | `https://app.example.com` | `http://192.168.x.x:8787` | ❌ | Mixed content blocked — use tunnel |
249
+ | `https://app.example.com` | `https://tunnel.ngrok.io` | ✅ | HTTPS-to-HTTPS via tunnel |
250
+
251
+ **Recommendation:** For production HTTPS environments, always use a tunnel (ngrok or Cloudflare Tunnel) to provide an HTTPS endpoint for the gateway.
252
+
253
+ ---
254
+
255
+ ## Context JSON
256
+
257
+ The `context_json` object captures browser context for each finding. All fields are optional; the gateway enforces a **10KB total budget** (`MAX_CONTEXT_JSON_BYTES = 10_240`).
258
+
259
+ | Field | Type | Description |
260
+ |---|---|---|
261
+ | `url` | `string` | Full page URL |
262
+ | `css_selector` | `string` | CSS selector of the target element |
263
+ | `element_text` | `string` | Text content of the target element |
264
+ | `viewport` | `{ width, height }` | Browser viewport dimensions |
265
+ | `route` | `string` | Current route/pathname |
266
+ | `screenshot_data_url` | `string` | Base64 `data:image/*` screenshot (pre-compressed to fit budget) |
267
+ | `user_agent` | `string` | Browser user agent |
268
+ | `batch_id` | `string` | Batch correlation ID |
269
+ | `extension_version` | `string` | SDK version |
270
+
271
+ ### Screenshot Size Limit
272
+
273
+ The `screenshot_data_url` must fit within the 10KB total `context_json` budget. The SDK pre-compresses screenshots before queuing. If the serialized context exceeds 10KB, the gateway rejects the batch with a `CONTEXT_TOO_LARGE` error.
274
+
275
+ ---
276
+
277
+ ## Error Handling
278
+
279
+ ```js
280
+ import { sendBatch } from '@synod-ai/extension-feedback';
281
+
282
+ try {
283
+ const result = await sendBatch();
284
+ // result.created: array of { id, title, severity }
285
+ // result.errors: array of { index, error }
286
+ } catch (err) {
287
+ // err.code: 'AUTH_ERROR' | 'RATE_LIMITED' | 'NETWORK_ERROR' | 'TIMEOUT' | 'SERVER_ERROR' | 'CONTEXT_TOO_LARGE'
288
+ console.error(`[${err.code}] ${err.message}`);
289
+ if (err.retryable) {
290
+ // SDK already retries automatically (maxRetries), but you can re-queue
291
+ }
292
+ }
293
+ ```
294
+
295
+ | Error Code | Retryable | Description |
296
+ |---|---|---|
297
+ | `AUTH_ERROR` | No | Invalid or expired token |
298
+ | `RATE_LIMITED` | Yes | Gateway rate limit hit (429) |
299
+ | `NETWORK_ERROR` | Yes | Connection failed |
300
+ | `TIMEOUT` | Yes | Request exceeded timeout |
301
+ | `SERVER_ERROR` | Yes | Gateway 5xx response |
302
+ | `CONTEXT_TOO_LARGE` | No | `context_json` exceeds 10KB budget |
303
+
304
+ ---
305
+
306
+ ## License
307
+
308
+ MIT
@@ -0,0 +1,253 @@
1
+ type FindingSeverity = 'low' | 'medium' | 'high';
2
+ interface FindingContextJson {
3
+ url?: string;
4
+ /** SPA route or pathname (best-effort). Helps the agent know which "screen" the user was on. */
5
+ route?: string;
6
+ /** Document title — gives the agent a human-readable label for the page. */
7
+ page_title?: string;
8
+ element_text?: string;
9
+ /** outerHTML of the selected element, truncated at ~1500 chars. */
10
+ element_html?: string;
11
+ /** Filtered attribute map of the selected element. */
12
+ element_attributes?: Record<string, string>;
13
+ /**
14
+ * Root-first parent chain, filtered to ancestors with an id or class.
15
+ * Generic wrappers are skipped on the client. Each entry is
16
+ * {tag, id?, className?}.
17
+ */
18
+ parent_chain?: Array<{
19
+ tag: string;
20
+ id?: string;
21
+ className?: string;
22
+ }>;
23
+ viewport?: {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ user_agent?: string;
28
+ screenshot_data_url?: string;
29
+ batch_id?: string;
30
+ extension_version?: string;
31
+ }
32
+ interface FindingPayload {
33
+ title: string;
34
+ description: string;
35
+ severity: FindingSeverity;
36
+ suggested_action?: 'create_story';
37
+ context_json: FindingContextJson;
38
+ }
39
+ interface ExternalFindingsRequest {
40
+ project_id: string;
41
+ findings: FindingPayload[];
42
+ }
43
+ interface CreatedFinding {
44
+ id: string;
45
+ title: string;
46
+ severity: FindingSeverity;
47
+ }
48
+ interface FindingError {
49
+ index: number;
50
+ error: string;
51
+ }
52
+ interface BatchResponse {
53
+ created: CreatedFinding[];
54
+ errors: FindingError[];
55
+ }
56
+ interface BatchResult {
57
+ created: CreatedFinding[];
58
+ errors: FindingError[];
59
+ }
60
+ type ClientErrorCode = 'AUTH_ERROR' | 'RATE_LIMITED' | 'NETWORK_ERROR' | 'TIMEOUT' | 'SERVER_ERROR' | 'CLIENT_ERROR' | 'CONTEXT_TOO_LARGE';
61
+ interface ClientError extends Error {
62
+ code: ClientErrorCode;
63
+ statusCode?: number;
64
+ retryable: boolean;
65
+ }
66
+ interface FeedbackConfig {
67
+ gatewayUrl: string;
68
+ projectId: string;
69
+ token: string;
70
+ keyCombo?: string;
71
+ timeout?: number;
72
+ maxRetries?: number;
73
+ }
74
+
75
+ declare class SynodFeedbackClient {
76
+ private readonly config;
77
+ private _queue;
78
+ private _abortControllers;
79
+ constructor(config: FeedbackConfig);
80
+ /**
81
+ * Add a finding to the local batch queue.
82
+ * Pre-validates context_json size to prevent oversized payloads.
83
+ */
84
+ queue(finding: FindingPayload): void;
85
+ /**
86
+ * Get the current queue length.
87
+ */
88
+ get queueLength(): number;
89
+ /**
90
+ * Get a shallow copy of the current queue.
91
+ */
92
+ getQueueSnapshot(): FindingPayload[];
93
+ /**
94
+ * Clear all items from the queue.
95
+ */
96
+ clearQueue(): void;
97
+ /**
98
+ * Send all queued findings as a batch to the gateway.
99
+ * On success (201): created items are dequeued; errored items remain.
100
+ * On full failure (after retries): entire batch remains in queue.
101
+ *
102
+ * @returns BatchResult with created and errors arrays
103
+ * @throws ClientError on full failure after retries, or on non-retryable error
104
+ */
105
+ sendBatch(): Promise<BatchResult>;
106
+ /**
107
+ * POST to the gateway with retry/backoff logic.
108
+ * Retries up to maxRetries times on retryable failures (5xx, 429, network, timeout).
109
+ * Does NOT retry on 401 or other 4xx (except 429).
110
+ *
111
+ * Worst-case wall time: ~1s + timeout + 2s + timeout + 4s + timeout.
112
+ */
113
+ private postWithRetry;
114
+ /**
115
+ * Process the gateway response: dequeue created items, retain errored items.
116
+ * Findings are matched by their position in the original sent batch.
117
+ */
118
+ private processResponse;
119
+ /**
120
+ * Remove successfully sent items from the live queue.
121
+ *
122
+ * The sentFindings were a snapshot of the queue at send time. We need to remove
123
+ * the successful ones while keeping the failed ones. Since they were all at the
124
+ * front of the queue (in order), we rebuild: remove createdIndices from [0..sentCount-1].
125
+ */
126
+ private dequeueByIndices;
127
+ private classifyHttpError;
128
+ private classifyTransportError;
129
+ /**
130
+ * Abort all in-flight requests.
131
+ */
132
+ destroy(): void;
133
+ private sleep;
134
+ }
135
+
136
+ type EnableHandler = () => void;
137
+ type DisableHandler = () => void;
138
+ /**
139
+ * Singleton lifecycle manager for the feedback extension.
140
+ *
141
+ * State machine:
142
+ * uninitialized → (init) → initialized (enabled=false)
143
+ * ↕ enable() / disable()
144
+ * initialized (enabled=true/false)
145
+ *
146
+ * The client and queue are ALWAYS available regardless of `enabled` state —
147
+ * `enabled` is a semantic signal for the interaction layer (SDG-294), not a gate on transport.
148
+ */
149
+ declare class FeedbackManager {
150
+ private _initialized;
151
+ private _enabled;
152
+ private _client;
153
+ private _config;
154
+ private _enableHandlers;
155
+ private _disableHandlers;
156
+ get initialized(): boolean;
157
+ get enabled(): boolean;
158
+ /** The active client instance, or null if not initialized. */
159
+ get client(): SynodFeedbackClient | null;
160
+ /** The validated config, or null if not initialized. */
161
+ get config(): FeedbackConfig | null;
162
+ /**
163
+ * Initialize the feedback system with configuration.
164
+ * Creates the singleton client instance. Re-init replaces existing state.
165
+ */
166
+ init(config: FeedbackConfig): void;
167
+ /**
168
+ * Enable the feedback system. Fires all registered onEnable handlers.
169
+ */
170
+ enable(): void;
171
+ /**
172
+ * Disable the feedback system. Fires all registered onDisable handlers.
173
+ */
174
+ disable(): void;
175
+ /**
176
+ * Register a handler to be called when enable() is invoked.
177
+ * If already enabled, the handler fires immediately.
178
+ * @returns unregister function
179
+ */
180
+ onEnable(handler: EnableHandler): () => void;
181
+ /**
182
+ * Register a handler to be called when disable() is invoked.
183
+ * @returns unregister function
184
+ */
185
+ onDisable(handler: DisableHandler): () => void;
186
+ queue(finding: FindingPayload): void;
187
+ sendBatch(): Promise<BatchResult>;
188
+ get queueLength(): number;
189
+ getQueueSnapshot(): FindingPayload[];
190
+ /**
191
+ * Clear all items from the queue.
192
+ */
193
+ clearQueue(): void;
194
+ /**
195
+ * Replace the entire queue with the given items.
196
+ * Used by the interaction layer for remove/edit operations.
197
+ */
198
+ replaceQueue(items: FindingPayload[]): void;
199
+ /**
200
+ * Reset the manager to uninitialized state.
201
+ * Destroys client, clears handlers.
202
+ */
203
+ destroy(): void;
204
+ private requireClient;
205
+ }
206
+
207
+ /**
208
+ * Encode an array of findings into the clipboard transfer format.
209
+ * Screenshots are stripped from every finding's context_json.
210
+ *
211
+ * Empty arrays still encode (returns the prefix + empty-list base64)
212
+ * so consumers can distinguish "Synod clipboard blob with no items" from
213
+ * "not a Synod clipboard blob at all".
214
+ */
215
+ declare function encodeFindingsToClipboard(findings: FindingPayload[]): string;
216
+ /**
217
+ * Decode a clipboard string back into findings.
218
+ *
219
+ * Returns `{ ok: true, findings }` on success, or `{ ok: false, error }`
220
+ * if the input is not a Synod findings blob, the JSON is malformed, the
221
+ * version is unsupported, or any finding has an invalid shape.
222
+ *
223
+ * On any decode error the consumer should fall back to plain-text paste
224
+ * behaviour (or just show an "invalid clipboard" toast).
225
+ */
226
+ declare function decodeFindingsFromClipboard(raw: string): {
227
+ ok: true;
228
+ findings: FindingPayload[];
229
+ } | {
230
+ ok: false;
231
+ error: string;
232
+ };
233
+
234
+ declare function init(config: FeedbackConfig): void;
235
+ declare function enable(): void;
236
+ declare function disable(): void;
237
+ declare function onEnable(handler: () => void): () => void;
238
+ declare function onDisable(handler: () => void): () => void;
239
+ declare function sendBatch(): Promise<BatchResult>;
240
+ declare function queue(finding: FindingPayload): void;
241
+ declare function clearQueue(): void;
242
+ declare function replaceQueue(items: FindingPayload[]): void;
243
+ declare function isEnabled(): boolean;
244
+ declare function getQueueSnapshot(): FindingPayload[];
245
+ declare function getQueueLength(): number;
246
+
247
+ /**
248
+ * @internal Reset singleton state. Intended for test isolation only.
249
+ * Creates a fresh manager and re-registers interaction layer hooks.
250
+ */
251
+ declare function __resetForTesting(): void;
252
+
253
+ export { type BatchResponse, type BatchResult, type ClientError, type ClientErrorCode, type CreatedFinding, type ExternalFindingsRequest, type FeedbackConfig, FeedbackManager, type FindingContextJson, type FindingError, type FindingPayload, type FindingSeverity, SynodFeedbackClient, __resetForTesting, clearQueue, decodeFindingsFromClipboard, disable, enable, encodeFindingsToClipboard, getQueueLength, getQueueSnapshot, init, isEnabled, onDisable, onEnable, queue, replaceQueue, sendBatch };