@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 +308 -0
- package/dist/index.d.cts +253 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +3213 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3160 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react.d.cts +24 -0
- package/dist/react.d.ts +24 -0
- package/dist/react.js +86 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +58 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +57 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3160 @@
|
|
|
1
|
+
// src/client/types.ts
|
|
2
|
+
function createClientError(code, message, statusCode, retryable = false) {
|
|
3
|
+
const err = new Error(message);
|
|
4
|
+
err.name = "ClientError";
|
|
5
|
+
err.code = code;
|
|
6
|
+
err.statusCode = statusCode;
|
|
7
|
+
err.retryable = retryable;
|
|
8
|
+
return err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/utils/retry.ts
|
|
12
|
+
var BACKOFF_DELAYS_MS = [1e3, 2e3, 4e3];
|
|
13
|
+
function getBackoffDelay(attempt) {
|
|
14
|
+
if (attempt < 0 || attempt >= BACKOFF_DELAYS_MS.length) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
return BACKOFF_DELAYS_MS[attempt];
|
|
18
|
+
}
|
|
19
|
+
function isRetryable(status, error) {
|
|
20
|
+
if (error instanceof TypeError) return true;
|
|
21
|
+
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
22
|
+
if (error && typeof error === "object" && "name" in error && error.name === "AbortError") {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (status === void 0) return true;
|
|
26
|
+
if (status >= 500 && status < 600) return true;
|
|
27
|
+
if (status === 429) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/utils/config.ts
|
|
32
|
+
var DEFAULT_GATEWAY_URL = "http://localhost:8787";
|
|
33
|
+
var DEFAULT_TIMEOUT = 15e3;
|
|
34
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
35
|
+
var DEFAULT_KEY_COMBO = "Ctrl+Shift+E";
|
|
36
|
+
function validateConfig(input) {
|
|
37
|
+
const missing = [];
|
|
38
|
+
if (!input.projectId || input.projectId.trim() === "") {
|
|
39
|
+
missing.push("projectId");
|
|
40
|
+
}
|
|
41
|
+
if (!input.token || input.token.trim() === "") {
|
|
42
|
+
missing.push("token");
|
|
43
|
+
}
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[SynodFeedback] Missing required config field(s): ${missing.join(", ")}. Please provide these in init() or <FeedbackProvider config={...}>.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const gatewayUrl = input.gatewayUrl ?? DEFAULT_GATEWAY_URL;
|
|
50
|
+
try {
|
|
51
|
+
new URL(gatewayUrl);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[SynodFeedback] Invalid gatewayUrl: "${gatewayUrl}". Must be a valid URL.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
gatewayUrl,
|
|
59
|
+
projectId: input.projectId,
|
|
60
|
+
token: input.token,
|
|
61
|
+
keyCombo: input.keyCombo ?? DEFAULT_KEY_COMBO,
|
|
62
|
+
timeout: input.timeout ?? DEFAULT_TIMEOUT,
|
|
63
|
+
maxRetries: input.maxRetries ?? DEFAULT_MAX_RETRIES
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
var MAX_CONTEXT_JSON_BYTES = 10240;
|
|
67
|
+
function isContextJsonValid(contextJson) {
|
|
68
|
+
const serialized = JSON.stringify(contextJson);
|
|
69
|
+
return new TextEncoder().encode(serialized).length <= MAX_CONTEXT_JSON_BYTES;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/client/SynodFeedbackClient.ts
|
|
73
|
+
var ENDPOINT_PATH = "/v2/findings/external";
|
|
74
|
+
var SynodFeedbackClient = class {
|
|
75
|
+
constructor(config) {
|
|
76
|
+
this._queue = [];
|
|
77
|
+
this._abortControllers = [];
|
|
78
|
+
this.config = config;
|
|
79
|
+
}
|
|
80
|
+
// ── Queue Management ────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* Add a finding to the local batch queue.
|
|
83
|
+
* Pre-validates context_json size to prevent oversized payloads.
|
|
84
|
+
*/
|
|
85
|
+
queue(finding) {
|
|
86
|
+
if (!isContextJsonValid(finding.context_json)) {
|
|
87
|
+
throw createClientError(
|
|
88
|
+
"CONTEXT_TOO_LARGE",
|
|
89
|
+
"context_json exceeds 10KB limit (serialized). Compress or remove screenshot data.",
|
|
90
|
+
void 0,
|
|
91
|
+
false
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
this._queue.push(finding);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get the current queue length.
|
|
98
|
+
*/
|
|
99
|
+
get queueLength() {
|
|
100
|
+
return this._queue.length;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get a shallow copy of the current queue.
|
|
104
|
+
*/
|
|
105
|
+
getQueueSnapshot() {
|
|
106
|
+
return [...this._queue];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Clear all items from the queue.
|
|
110
|
+
*/
|
|
111
|
+
clearQueue() {
|
|
112
|
+
this._queue = [];
|
|
113
|
+
}
|
|
114
|
+
// ── Batch Send ──────────────────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Send all queued findings as a batch to the gateway.
|
|
117
|
+
* On success (201): created items are dequeued; errored items remain.
|
|
118
|
+
* On full failure (after retries): entire batch remains in queue.
|
|
119
|
+
*
|
|
120
|
+
* @returns BatchResult with created and errors arrays
|
|
121
|
+
* @throws ClientError on full failure after retries, or on non-retryable error
|
|
122
|
+
*/
|
|
123
|
+
async sendBatch() {
|
|
124
|
+
if (this._queue.length === 0) {
|
|
125
|
+
return { created: [], errors: [] };
|
|
126
|
+
}
|
|
127
|
+
const findingsToSend = [...this._queue];
|
|
128
|
+
const requestBody = {
|
|
129
|
+
project_id: this.config.projectId,
|
|
130
|
+
findings: findingsToSend
|
|
131
|
+
};
|
|
132
|
+
const response = await this.postWithRetry(requestBody);
|
|
133
|
+
const batchResult = this.processResponse(response, findingsToSend);
|
|
134
|
+
return batchResult;
|
|
135
|
+
}
|
|
136
|
+
// ── HTTP Transport with Retry ───────────────────────────────────────
|
|
137
|
+
/**
|
|
138
|
+
* POST to the gateway with retry/backoff logic.
|
|
139
|
+
* Retries up to maxRetries times on retryable failures (5xx, 429, network, timeout).
|
|
140
|
+
* Does NOT retry on 401 or other 4xx (except 429).
|
|
141
|
+
*
|
|
142
|
+
* Worst-case wall time: ~1s + timeout + 2s + timeout + 4s + timeout.
|
|
143
|
+
*/
|
|
144
|
+
async postWithRetry(body) {
|
|
145
|
+
const maxRetries = this.config.maxRetries ?? 3;
|
|
146
|
+
let lastError = null;
|
|
147
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
148
|
+
if (attempt > 0) {
|
|
149
|
+
const delay = getBackoffDelay(attempt - 1);
|
|
150
|
+
if (delay !== void 0) {
|
|
151
|
+
await this.sleep(delay);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
this._abortControllers.push(controller);
|
|
156
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(`${this.config.gatewayUrl}${ENDPOINT_PATH}`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
Authorization: `Bearer ${this.config.token}`
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify(body),
|
|
165
|
+
signal: controller.signal
|
|
166
|
+
});
|
|
167
|
+
clearTimeout(timeoutId);
|
|
168
|
+
if (res.ok) {
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
172
|
+
const clientErr = this.classifyHttpError(res.status);
|
|
173
|
+
if (!isRetryable(res.status, null) || attempt >= maxRetries) {
|
|
174
|
+
throw clientErr;
|
|
175
|
+
}
|
|
176
|
+
lastError = clientErr;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
clearTimeout(timeoutId);
|
|
179
|
+
if (isClientError(err)) {
|
|
180
|
+
if (!err.retryable || attempt >= maxRetries) {
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
lastError = err;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const classified = this.classifyTransportError(err);
|
|
187
|
+
if (!classified.retryable || attempt >= maxRetries) {
|
|
188
|
+
throw classified;
|
|
189
|
+
}
|
|
190
|
+
lastError = classified;
|
|
191
|
+
} finally {
|
|
192
|
+
const idx = this._abortControllers.indexOf(controller);
|
|
193
|
+
if (idx >= 0) {
|
|
194
|
+
this._abortControllers.splice(idx, 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
throw lastError ?? createClientError(
|
|
199
|
+
"NETWORK_ERROR",
|
|
200
|
+
"All retry attempts exhausted.",
|
|
201
|
+
void 0,
|
|
202
|
+
true
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
// ── Response Processing ─────────────────────────────────────────────
|
|
206
|
+
/**
|
|
207
|
+
* Process the gateway response: dequeue created items, retain errored items.
|
|
208
|
+
* Findings are matched by their position in the original sent batch.
|
|
209
|
+
*/
|
|
210
|
+
processResponse(response, sentFindings) {
|
|
211
|
+
const errorIndices = new Set(response.errors.map((e) => e.index));
|
|
212
|
+
const createdIndices = [];
|
|
213
|
+
for (let i = 0; i < sentFindings.length; i++) {
|
|
214
|
+
if (!errorIndices.has(i)) {
|
|
215
|
+
createdIndices.push(i);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.dequeueByIndices(createdIndices, sentFindings.length);
|
|
219
|
+
return {
|
|
220
|
+
created: response.created,
|
|
221
|
+
errors: response.errors
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove successfully sent items from the live queue.
|
|
226
|
+
*
|
|
227
|
+
* The sentFindings were a snapshot of the queue at send time. We need to remove
|
|
228
|
+
* the successful ones while keeping the failed ones. Since they were all at the
|
|
229
|
+
* front of the queue (in order), we rebuild: remove createdIndices from [0..sentCount-1].
|
|
230
|
+
*/
|
|
231
|
+
dequeueByIndices(keepIndices, sentCount) {
|
|
232
|
+
const removeIndices = new Set(keepIndices);
|
|
233
|
+
const remaining = [];
|
|
234
|
+
for (let i = 0; i < this._queue.length; i++) {
|
|
235
|
+
if (i < sentCount) {
|
|
236
|
+
if (!removeIndices.has(i)) {
|
|
237
|
+
remaining.push(this._queue[i]);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
remaining.push(this._queue[i]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this._queue = remaining;
|
|
244
|
+
}
|
|
245
|
+
// ── Error Classification ────────────────────────────────────────────
|
|
246
|
+
classifyHttpError(status) {
|
|
247
|
+
switch (true) {
|
|
248
|
+
case status === 401:
|
|
249
|
+
return createClientError(
|
|
250
|
+
"AUTH_ERROR",
|
|
251
|
+
"Authentication failed (401). Token is invalid or expired.",
|
|
252
|
+
401,
|
|
253
|
+
false
|
|
254
|
+
);
|
|
255
|
+
case status === 429:
|
|
256
|
+
return createClientError(
|
|
257
|
+
"RATE_LIMITED",
|
|
258
|
+
"Rate limited (429). Too many findings in the time window.",
|
|
259
|
+
429,
|
|
260
|
+
true
|
|
261
|
+
);
|
|
262
|
+
case (status >= 500 && status < 600):
|
|
263
|
+
return createClientError(
|
|
264
|
+
"SERVER_ERROR",
|
|
265
|
+
`Server error (${status}).`,
|
|
266
|
+
status,
|
|
267
|
+
true
|
|
268
|
+
);
|
|
269
|
+
case (status >= 400 && status < 500):
|
|
270
|
+
return createClientError(
|
|
271
|
+
"CLIENT_ERROR",
|
|
272
|
+
`Client error (${status}). Request rejected.`,
|
|
273
|
+
status,
|
|
274
|
+
false
|
|
275
|
+
);
|
|
276
|
+
default:
|
|
277
|
+
return createClientError(
|
|
278
|
+
"CLIENT_ERROR",
|
|
279
|
+
`Unexpected status ${status}.`,
|
|
280
|
+
status,
|
|
281
|
+
false
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
classifyTransportError(err) {
|
|
286
|
+
if (err instanceof TypeError) {
|
|
287
|
+
return createClientError(
|
|
288
|
+
"NETWORK_ERROR",
|
|
289
|
+
`Network error: ${err.message}`,
|
|
290
|
+
void 0,
|
|
291
|
+
true
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (err instanceof DOMException && err.name === "AbortError" || err && typeof err === "object" && "name" in err && err.name === "AbortError") {
|
|
295
|
+
return createClientError(
|
|
296
|
+
"TIMEOUT",
|
|
297
|
+
`Request timed out after ${this.config.timeout}ms.`,
|
|
298
|
+
void 0,
|
|
299
|
+
true
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
303
|
+
return createClientError(
|
|
304
|
+
"NETWORK_ERROR",
|
|
305
|
+
`Unexpected transport error: ${message}`,
|
|
306
|
+
void 0,
|
|
307
|
+
false
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Abort all in-flight requests.
|
|
313
|
+
*/
|
|
314
|
+
destroy() {
|
|
315
|
+
for (const controller of this._abortControllers) {
|
|
316
|
+
try {
|
|
317
|
+
controller.abort();
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
this._abortControllers = [];
|
|
322
|
+
}
|
|
323
|
+
// ── Utilities ───────────────────────────────────────────────────────
|
|
324
|
+
sleep(ms) {
|
|
325
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
function isClientError(err) {
|
|
329
|
+
return err instanceof Error && "code" in err && "retryable" in err;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/lifecycle/FeedbackManager.ts
|
|
333
|
+
var FeedbackManager = class {
|
|
334
|
+
constructor() {
|
|
335
|
+
this._initialized = false;
|
|
336
|
+
this._enabled = false;
|
|
337
|
+
this._client = null;
|
|
338
|
+
this._config = null;
|
|
339
|
+
this._enableHandlers = [];
|
|
340
|
+
this._disableHandlers = [];
|
|
341
|
+
}
|
|
342
|
+
get initialized() {
|
|
343
|
+
return this._initialized;
|
|
344
|
+
}
|
|
345
|
+
get enabled() {
|
|
346
|
+
return this._enabled;
|
|
347
|
+
}
|
|
348
|
+
/** The active client instance, or null if not initialized. */
|
|
349
|
+
get client() {
|
|
350
|
+
return this._client;
|
|
351
|
+
}
|
|
352
|
+
/** The validated config, or null if not initialized. */
|
|
353
|
+
get config() {
|
|
354
|
+
return this._config;
|
|
355
|
+
}
|
|
356
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
357
|
+
/**
|
|
358
|
+
* Initialize the feedback system with configuration.
|
|
359
|
+
* Creates the singleton client instance. Re-init replaces existing state.
|
|
360
|
+
*/
|
|
361
|
+
init(config) {
|
|
362
|
+
const validated = validateConfig(config);
|
|
363
|
+
if (this._client) {
|
|
364
|
+
this._client.destroy();
|
|
365
|
+
}
|
|
366
|
+
this._client = new SynodFeedbackClient(validated);
|
|
367
|
+
this._config = validated;
|
|
368
|
+
this._initialized = true;
|
|
369
|
+
this._enabled = false;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Enable the feedback system. Fires all registered onEnable handlers.
|
|
373
|
+
*/
|
|
374
|
+
enable() {
|
|
375
|
+
if (!this._initialized) {
|
|
376
|
+
throw new Error("[SynodFeedback] Cannot enable() before init(). Call init(config) first.");
|
|
377
|
+
}
|
|
378
|
+
if (this._enabled) return;
|
|
379
|
+
this._enabled = true;
|
|
380
|
+
for (const handler of [...this._enableHandlers]) {
|
|
381
|
+
handler();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Disable the feedback system. Fires all registered onDisable handlers.
|
|
386
|
+
*/
|
|
387
|
+
disable() {
|
|
388
|
+
if (!this._initialized) return;
|
|
389
|
+
if (!this._enabled) return;
|
|
390
|
+
this._enabled = false;
|
|
391
|
+
for (const handler of [...this._disableHandlers]) {
|
|
392
|
+
handler();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ── Extension Hooks (for SDG-294) ───────────────────────────────────
|
|
396
|
+
/**
|
|
397
|
+
* Register a handler to be called when enable() is invoked.
|
|
398
|
+
* If already enabled, the handler fires immediately.
|
|
399
|
+
* @returns unregister function
|
|
400
|
+
*/
|
|
401
|
+
onEnable(handler) {
|
|
402
|
+
this._enableHandlers.push(handler);
|
|
403
|
+
if (this._enabled) {
|
|
404
|
+
handler();
|
|
405
|
+
}
|
|
406
|
+
return () => {
|
|
407
|
+
const idx = this._enableHandlers.indexOf(handler);
|
|
408
|
+
if (idx >= 0) this._enableHandlers.splice(idx, 1);
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Register a handler to be called when disable() is invoked.
|
|
413
|
+
* @returns unregister function
|
|
414
|
+
*/
|
|
415
|
+
onDisable(handler) {
|
|
416
|
+
this._disableHandlers.push(handler);
|
|
417
|
+
return () => {
|
|
418
|
+
const idx = this._disableHandlers.indexOf(handler);
|
|
419
|
+
if (idx >= 0) this._disableHandlers.splice(idx, 1);
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// ── Convenience: delegate to client ─────────────────────────────────
|
|
423
|
+
queue(finding) {
|
|
424
|
+
this.requireClient();
|
|
425
|
+
this._client.queue(finding);
|
|
426
|
+
}
|
|
427
|
+
async sendBatch() {
|
|
428
|
+
this.requireClient();
|
|
429
|
+
return this._client.sendBatch();
|
|
430
|
+
}
|
|
431
|
+
get queueLength() {
|
|
432
|
+
return this._client?.queueLength ?? 0;
|
|
433
|
+
}
|
|
434
|
+
getQueueSnapshot() {
|
|
435
|
+
return this._client?.getQueueSnapshot() ?? [];
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Clear all items from the queue.
|
|
439
|
+
*/
|
|
440
|
+
clearQueue() {
|
|
441
|
+
this._client?.clearQueue();
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Replace the entire queue with the given items.
|
|
445
|
+
* Used by the interaction layer for remove/edit operations.
|
|
446
|
+
*/
|
|
447
|
+
replaceQueue(items) {
|
|
448
|
+
if (!this._client) return;
|
|
449
|
+
this._client.clearQueue();
|
|
450
|
+
for (const item of items) {
|
|
451
|
+
this._client.queue(item);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ── Teardown ────────────────────────────────────────────────────────
|
|
455
|
+
/**
|
|
456
|
+
* Reset the manager to uninitialized state.
|
|
457
|
+
* Destroys client, clears handlers.
|
|
458
|
+
*/
|
|
459
|
+
destroy() {
|
|
460
|
+
this._client?.destroy();
|
|
461
|
+
this._client = null;
|
|
462
|
+
this._initialized = false;
|
|
463
|
+
this._enabled = false;
|
|
464
|
+
this._enableHandlers = [];
|
|
465
|
+
this._disableHandlers = [];
|
|
466
|
+
}
|
|
467
|
+
requireClient() {
|
|
468
|
+
if (!this._initialized || !this._client) {
|
|
469
|
+
throw new Error("[SynodFeedback] Not initialized. Call init(config) first.");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/utils/overlay-css.ts
|
|
475
|
+
var OVERLAY_CSS = `
|
|
476
|
+
:host {
|
|
477
|
+
all: initial;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
*, *::before, *::after {
|
|
481
|
+
box-sizing: border-box;
|
|
482
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* \u2500\u2500 Shared variables (internal only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
486
|
+
.synod-fb {
|
|
487
|
+
--fb-bg-panel: #1e293b;
|
|
488
|
+
--fb-bg-elevated: #334155;
|
|
489
|
+
--fb-bg-input: #0f172a;
|
|
490
|
+
--fb-bg-hover: #475569;
|
|
491
|
+
--fb-border: #475569;
|
|
492
|
+
--fb-border-subtle: #334155;
|
|
493
|
+
--fb-text-primary: #f1f5f9;
|
|
494
|
+
--fb-text-secondary: #94a3b8;
|
|
495
|
+
--fb-text-muted: #64748b;
|
|
496
|
+
--fb-accent: #0d9488;
|
|
497
|
+
--fb-accent-hover: #0f766e;
|
|
498
|
+
--fb-highlight: #2DAFAF;
|
|
499
|
+
--fb-highlight-fill: rgba(45, 175, 175, 0.08);
|
|
500
|
+
--fb-sev-low: #64748b;
|
|
501
|
+
--fb-sev-medium: #d97706;
|
|
502
|
+
--fb-sev-high: #dc2626;
|
|
503
|
+
--fb-success: #059669;
|
|
504
|
+
--fb-error: #dc2626;
|
|
505
|
+
--fb-radius-sm: 4px;
|
|
506
|
+
--fb-radius-md: 8px;
|
|
507
|
+
--fb-radius-lg: 12px;
|
|
508
|
+
--fb-shadow-panel: 0 8px 32px rgba(0,0,0,0.3);
|
|
509
|
+
--fb-shadow-popover: 0 4px 16px rgba(0,0,0,0.2);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* \u2500\u2500 Highlighter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
513
|
+
.synod-fb-highlighter {
|
|
514
|
+
position: absolute;
|
|
515
|
+
pointer-events: none;
|
|
516
|
+
border: 2px dashed #2DAFAF;
|
|
517
|
+
background: rgba(45, 175, 175, 0.08);
|
|
518
|
+
border-radius: 2px;
|
|
519
|
+
z-index: 1;
|
|
520
|
+
transition: all 0ms;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.synod-fb-inspector {
|
|
524
|
+
position: absolute;
|
|
525
|
+
pointer-events: none;
|
|
526
|
+
background: #1F8F8F;
|
|
527
|
+
color: #ffffff;
|
|
528
|
+
font-size: 11px;
|
|
529
|
+
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
530
|
+
line-height: 20px;
|
|
531
|
+
height: 20px;
|
|
532
|
+
padding: 0 8px;
|
|
533
|
+
border-radius: 4px;
|
|
534
|
+
white-space: nowrap;
|
|
535
|
+
overflow: hidden;
|
|
536
|
+
text-overflow: ellipsis;
|
|
537
|
+
max-width: 400px;
|
|
538
|
+
z-index: 2;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.synod-fb-highlighter-logo {
|
|
542
|
+
position: absolute;
|
|
543
|
+
pointer-events: none;
|
|
544
|
+
display: flex;
|
|
545
|
+
align-items: center;
|
|
546
|
+
justify-content: center;
|
|
547
|
+
width: 20px;
|
|
548
|
+
height: 20px;
|
|
549
|
+
background: #0f172a;
|
|
550
|
+
border: 1px solid #2DAFAF;
|
|
551
|
+
border-radius: 4px;
|
|
552
|
+
z-index: 3;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* \u2500\u2500 Floating logo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
556
|
+
.synod-fb-logo {
|
|
557
|
+
position: absolute;
|
|
558
|
+
width: 36px;
|
|
559
|
+
height: 36px;
|
|
560
|
+
display: flex;
|
|
561
|
+
align-items: center;
|
|
562
|
+
justify-content: center;
|
|
563
|
+
cursor: grab;
|
|
564
|
+
user-select: none;
|
|
565
|
+
pointer-events: auto;
|
|
566
|
+
transition: transform 150ms ease-out, filter 150ms ease-out;
|
|
567
|
+
z-index: 10;
|
|
568
|
+
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.25));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.synod-fb-logo svg {
|
|
572
|
+
width: 32px;
|
|
573
|
+
height: 32px;
|
|
574
|
+
pointer-events: none;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.synod-fb-logo:hover {
|
|
578
|
+
transform: scale(1.08);
|
|
579
|
+
filter: drop-shadow(0 4px 10px rgba(0,0,0,0.35));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.synod-fb-logo:active {
|
|
583
|
+
cursor: grabbing;
|
|
584
|
+
transform: scale(0.96);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.synod-fb-logo-badge {
|
|
588
|
+
position: absolute;
|
|
589
|
+
top: -3px;
|
|
590
|
+
right: -3px;
|
|
591
|
+
min-width: 18px;
|
|
592
|
+
height: 18px;
|
|
593
|
+
border-radius: 9px;
|
|
594
|
+
/* Synod teal \u2014 replaces the previous generic red. Reads as brand chrome
|
|
595
|
+
rather than a notifications-style alert. */
|
|
596
|
+
background: #0d9488;
|
|
597
|
+
color: #ffffff;
|
|
598
|
+
font-size: 11px;
|
|
599
|
+
font-weight: 700;
|
|
600
|
+
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
601
|
+
line-height: 18px;
|
|
602
|
+
text-align: center;
|
|
603
|
+
padding: 0 4px;
|
|
604
|
+
letter-spacing: -0.2px;
|
|
605
|
+
/* Double-ring outline: white inner ring guarantees contrast on dark
|
|
606
|
+
backgrounds; the semi-opaque dark outer ring guarantees contrast on
|
|
607
|
+
light backgrounds. Together they pop on any backdrop without needing
|
|
608
|
+
per-site tuning. */
|
|
609
|
+
border: 2px solid #ffffff;
|
|
610
|
+
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.55), 0 1px 3px rgba(0, 0, 0, 0.25);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.synod-fb-logo-badge-pulse {
|
|
614
|
+
animation: synod-fb-badge-pulse 300ms ease-in-out;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
@keyframes synod-fb-badge-pulse {
|
|
618
|
+
0%, 100% { transform: scale(1); }
|
|
619
|
+
50% { transform: scale(1.15); }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* \u2500\u2500 Logo context menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
623
|
+
.synod-fb-context-menu {
|
|
624
|
+
position: absolute;
|
|
625
|
+
min-width: 160px;
|
|
626
|
+
background: #1e293b;
|
|
627
|
+
border: 1px solid #475569;
|
|
628
|
+
border-radius: 6px;
|
|
629
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
|
630
|
+
padding: 4px;
|
|
631
|
+
z-index: 50;
|
|
632
|
+
pointer-events: auto;
|
|
633
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
634
|
+
animation: synod-fb-fade-in 100ms ease-out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.synod-fb-context-menu-item {
|
|
638
|
+
display: block;
|
|
639
|
+
width: 100%;
|
|
640
|
+
text-align: left;
|
|
641
|
+
padding: 6px 10px;
|
|
642
|
+
background: transparent;
|
|
643
|
+
border: none;
|
|
644
|
+
border-radius: 4px;
|
|
645
|
+
color: #f1f5f9;
|
|
646
|
+
font-size: 12px;
|
|
647
|
+
font-weight: 500;
|
|
648
|
+
cursor: pointer;
|
|
649
|
+
font-family: inherit;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.synod-fb-context-menu-item:hover {
|
|
653
|
+
background: #dc2626;
|
|
654
|
+
color: #fff;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* \u2500\u2500 Charge indicator \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
658
|
+
.synod-fb-charge {
|
|
659
|
+
position: absolute;
|
|
660
|
+
pointer-events: none;
|
|
661
|
+
z-index: 15;
|
|
662
|
+
display: flex;
|
|
663
|
+
align-items: center;
|
|
664
|
+
justify-content: center;
|
|
665
|
+
width: 40px;
|
|
666
|
+
height: 40px;
|
|
667
|
+
transform: translate(-50%, -50%);
|
|
668
|
+
transition: transform 120ms ease-out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.synod-fb-charge-ring {
|
|
672
|
+
position: absolute;
|
|
673
|
+
inset: 0;
|
|
674
|
+
width: 40px;
|
|
675
|
+
height: 40px;
|
|
676
|
+
transform: rotate(-90deg);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.synod-fb-charge-ring-bg {
|
|
680
|
+
fill: none;
|
|
681
|
+
stroke: rgba(45, 175, 175, 0.18);
|
|
682
|
+
stroke-width: 3;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.synod-fb-charge-ring-fg {
|
|
686
|
+
fill: none;
|
|
687
|
+
stroke: #2DAFAF;
|
|
688
|
+
stroke-width: 3;
|
|
689
|
+
stroke-linecap: round;
|
|
690
|
+
transition: stroke-dashoffset 60ms linear, stroke 200ms ease-out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.synod-fb-charge svg:not(.synod-fb-charge-ring) {
|
|
694
|
+
width: 22px;
|
|
695
|
+
height: 22px;
|
|
696
|
+
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.synod-fb-charge.charged {
|
|
700
|
+
transform: translate(-50%, -50%) scale(1.15);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.synod-fb-charge.charged .synod-fb-charge-ring-fg {
|
|
704
|
+
stroke: #36D6D6;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* \u2500\u2500 IssueForm \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
708
|
+
.synod-fb-form {
|
|
709
|
+
position: absolute;
|
|
710
|
+
width: 340px;
|
|
711
|
+
max-height: 90vh;
|
|
712
|
+
overflow-y: auto;
|
|
713
|
+
background: #1e293b;
|
|
714
|
+
border: 1px solid #475569;
|
|
715
|
+
border-radius: 8px;
|
|
716
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
717
|
+
z-index: 20;
|
|
718
|
+
pointer-events: auto;
|
|
719
|
+
color: #f1f5f9;
|
|
720
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
721
|
+
animation: synod-fb-fade-in 150ms ease-out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
@keyframes synod-fb-fade-in {
|
|
725
|
+
from { opacity: 0; }
|
|
726
|
+
to { opacity: 1; }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.synod-fb-form-header {
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: center;
|
|
732
|
+
justify-content: space-between;
|
|
733
|
+
padding: 8px 12px;
|
|
734
|
+
background: #334155;
|
|
735
|
+
border-radius: 8px 8px 0 0;
|
|
736
|
+
border-bottom: 1px solid #475569;
|
|
737
|
+
gap: 8px;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.synod-fb-form-header-logo {
|
|
741
|
+
display: flex;
|
|
742
|
+
align-items: center;
|
|
743
|
+
flex-shrink: 0;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.synod-fb-form-header-text {
|
|
747
|
+
flex: 1;
|
|
748
|
+
font-size: 11px;
|
|
749
|
+
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
750
|
+
color: #94a3b8;
|
|
751
|
+
white-space: nowrap;
|
|
752
|
+
overflow: hidden;
|
|
753
|
+
text-overflow: ellipsis;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.synod-fb-form-close {
|
|
757
|
+
background: none;
|
|
758
|
+
border: none;
|
|
759
|
+
color: #94a3b8;
|
|
760
|
+
cursor: pointer;
|
|
761
|
+
font-size: 16px;
|
|
762
|
+
padding: 0 4px;
|
|
763
|
+
line-height: 1;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.synod-fb-form-close:hover { color: #f1f5f9; }
|
|
767
|
+
|
|
768
|
+
.synod-fb-form-body { padding: 12px; }
|
|
769
|
+
|
|
770
|
+
.synod-fb-field { margin-bottom: 12px; }
|
|
771
|
+
|
|
772
|
+
.synod-fb-label {
|
|
773
|
+
display: block;
|
|
774
|
+
font-size: 11px;
|
|
775
|
+
font-weight: 500;
|
|
776
|
+
color: #94a3b8;
|
|
777
|
+
margin-bottom: 4px;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.synod-fb-input,
|
|
781
|
+
.synod-fb-textarea {
|
|
782
|
+
width: 100%;
|
|
783
|
+
background: #0f172a;
|
|
784
|
+
border: 1px solid #475569;
|
|
785
|
+
border-radius: 4px;
|
|
786
|
+
color: #f1f5f9;
|
|
787
|
+
font-size: 13px;
|
|
788
|
+
font-family: inherit;
|
|
789
|
+
padding: 8px 10px;
|
|
790
|
+
outline: none;
|
|
791
|
+
resize: vertical;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.synod-fb-input:focus,
|
|
795
|
+
.synod-fb-textarea:focus {
|
|
796
|
+
border-color: #0d9488;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.synod-fb-textarea {
|
|
800
|
+
min-height: 48px;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.synod-fb-severity-group {
|
|
804
|
+
display: flex;
|
|
805
|
+
gap: 0;
|
|
806
|
+
border-radius: 4px;
|
|
807
|
+
overflow: hidden;
|
|
808
|
+
border: 1px solid #475569;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.synod-fb-sev-btn {
|
|
812
|
+
flex: 1;
|
|
813
|
+
padding: 6px 0;
|
|
814
|
+
border: none;
|
|
815
|
+
background: transparent;
|
|
816
|
+
color: #94a3b8;
|
|
817
|
+
font-size: 12px;
|
|
818
|
+
font-weight: 500;
|
|
819
|
+
cursor: pointer;
|
|
820
|
+
transition: background 100ms;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.synod-fb-sev-btn:not(:last-child) {
|
|
824
|
+
border-right: 1px solid #475569;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.synod-fb-sev-btn:hover { background: #475569; }
|
|
828
|
+
|
|
829
|
+
.synod-fb-sev-btn.active[data-sev="low"] { background: #64748b; color: #fff; }
|
|
830
|
+
.synod-fb-sev-btn.active[data-sev="medium"] { background: #d97706; color: #fff; }
|
|
831
|
+
.synod-fb-sev-btn.active[data-sev="high"] { background: #dc2626; color: #fff; }
|
|
832
|
+
|
|
833
|
+
.synod-fb-screenshot-toggle {
|
|
834
|
+
display: flex;
|
|
835
|
+
align-items: center;
|
|
836
|
+
gap: 8px;
|
|
837
|
+
padding: 8px 10px;
|
|
838
|
+
background: #0f172a;
|
|
839
|
+
border: 1px solid #475569;
|
|
840
|
+
border-radius: 4px;
|
|
841
|
+
cursor: pointer;
|
|
842
|
+
font-size: 12px;
|
|
843
|
+
color: #94a3b8;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.synod-fb-screenshot-toggle:hover { border-color: #64748b; }
|
|
847
|
+
|
|
848
|
+
.synod-fb-screenshot-toggle input { accent-color: #0d9488; }
|
|
849
|
+
|
|
850
|
+
.synod-fb-screenshot-toggle.disabled {
|
|
851
|
+
opacity: 0.6;
|
|
852
|
+
cursor: not-allowed;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.synod-fb-screenshot-toggle.unavailable {
|
|
856
|
+
color: #d97706;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
.synod-fb-form-actions {
|
|
860
|
+
display: flex;
|
|
861
|
+
gap: 8px;
|
|
862
|
+
margin-top: 4px;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
.synod-fb-btn-secondary {
|
|
866
|
+
flex: 1;
|
|
867
|
+
padding: 8px 12px;
|
|
868
|
+
border: 1px solid #0d9488;
|
|
869
|
+
background: transparent;
|
|
870
|
+
color: #0d9488;
|
|
871
|
+
font-size: 12px;
|
|
872
|
+
font-weight: 500;
|
|
873
|
+
border-radius: 4px;
|
|
874
|
+
cursor: pointer;
|
|
875
|
+
font-family: inherit;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.synod-fb-btn-secondary:hover { background: #0d9488; color: #fff; }
|
|
879
|
+
|
|
880
|
+
.synod-fb-btn-primary {
|
|
881
|
+
flex: 1;
|
|
882
|
+
padding: 8px 12px;
|
|
883
|
+
border: none;
|
|
884
|
+
background: #0d9488;
|
|
885
|
+
color: #fff;
|
|
886
|
+
font-size: 12px;
|
|
887
|
+
font-weight: 500;
|
|
888
|
+
border-radius: 4px;
|
|
889
|
+
cursor: pointer;
|
|
890
|
+
font-family: inherit;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
.synod-fb-btn-primary:hover { background: #0f766e; }
|
|
894
|
+
.synod-fb-btn-primary:active { transform: scale(0.98); }
|
|
895
|
+
.synod-fb-btn-primary:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
|
|
896
|
+
|
|
897
|
+
/* \u2500\u2500 QueuePanel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
898
|
+
.synod-fb-queue {
|
|
899
|
+
position: absolute;
|
|
900
|
+
top: 16px;
|
|
901
|
+
right: 16px;
|
|
902
|
+
width: 380px;
|
|
903
|
+
max-width: calc(100vw - 32px);
|
|
904
|
+
max-height: min(520px, calc(100vh - 32px));
|
|
905
|
+
background: #1e293b;
|
|
906
|
+
border: 1px solid #475569;
|
|
907
|
+
border-radius: 8px;
|
|
908
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
909
|
+
z-index: 30;
|
|
910
|
+
pointer-events: auto;
|
|
911
|
+
display: flex;
|
|
912
|
+
flex-direction: column;
|
|
913
|
+
color: #f1f5f9;
|
|
914
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
915
|
+
animation: synod-fb-slide-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
@keyframes synod-fb-slide-in {
|
|
919
|
+
from { transform: translateX(400px); opacity: 0; }
|
|
920
|
+
to { transform: translateX(0); opacity: 1; }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.synod-fb-queue-header {
|
|
924
|
+
display: flex;
|
|
925
|
+
align-items: center;
|
|
926
|
+
justify-content: space-between;
|
|
927
|
+
padding: 12px 16px;
|
|
928
|
+
border-bottom: 1px solid #334155;
|
|
929
|
+
gap: 8px;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
.synod-fb-queue-header-logo {
|
|
933
|
+
display: flex;
|
|
934
|
+
align-items: center;
|
|
935
|
+
flex-shrink: 0;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
.synod-fb-queue-title {
|
|
939
|
+
font-size: 14px;
|
|
940
|
+
font-weight: 600;
|
|
941
|
+
color: #f1f5f9;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
.synod-fb-queue-body {
|
|
945
|
+
flex: 1;
|
|
946
|
+
overflow-y: auto;
|
|
947
|
+
padding: 8px;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.synod-fb-queue-empty {
|
|
951
|
+
padding: 24px 16px;
|
|
952
|
+
text-align: center;
|
|
953
|
+
color: #64748b;
|
|
954
|
+
font-size: 13px;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.synod-fb-queue-item {
|
|
958
|
+
display: flex;
|
|
959
|
+
align-items: flex-start;
|
|
960
|
+
gap: 8px;
|
|
961
|
+
padding: 8px;
|
|
962
|
+
border-radius: 4px;
|
|
963
|
+
border: 1px solid transparent;
|
|
964
|
+
margin-bottom: 4px;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.synod-fb-queue-item:hover { background: #334155; }
|
|
968
|
+
|
|
969
|
+
.synod-fb-queue-item-error {
|
|
970
|
+
font-size: 11px;
|
|
971
|
+
color: #dc2626;
|
|
972
|
+
padding: 4px 8px 8px;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.synod-fb-queue-item-thumb {
|
|
976
|
+
width: 44px;
|
|
977
|
+
height: 44px;
|
|
978
|
+
min-width: 44px;
|
|
979
|
+
border-radius: 4px;
|
|
980
|
+
border: 1px dashed #475569;
|
|
981
|
+
display: flex;
|
|
982
|
+
align-items: center;
|
|
983
|
+
justify-content: center;
|
|
984
|
+
overflow: hidden;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.synod-fb-queue-item-thumb img {
|
|
988
|
+
width: 100%;
|
|
989
|
+
height: 100%;
|
|
990
|
+
object-fit: cover;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.synod-fb-queue-item-content {
|
|
994
|
+
flex: 1;
|
|
995
|
+
min-width: 0;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.synod-fb-queue-item-title {
|
|
999
|
+
font-size: 12px;
|
|
1000
|
+
color: #f1f5f9;
|
|
1001
|
+
white-space: nowrap;
|
|
1002
|
+
overflow: hidden;
|
|
1003
|
+
text-overflow: ellipsis;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.synod-fb-queue-item-title.synod-fb-pending {
|
|
1007
|
+
color: #64748b;
|
|
1008
|
+
font-style: italic;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.synod-fb-queue-item-meta {
|
|
1012
|
+
font-size: 10px;
|
|
1013
|
+
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
1014
|
+
color: #64748b;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.synod-fb-queue-item-actions {
|
|
1018
|
+
display: flex;
|
|
1019
|
+
gap: 4px;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.synod-fb-icon-btn {
|
|
1023
|
+
background: none;
|
|
1024
|
+
border: none;
|
|
1025
|
+
color: #94a3b8;
|
|
1026
|
+
cursor: pointer;
|
|
1027
|
+
font-size: 14px;
|
|
1028
|
+
padding: 2px;
|
|
1029
|
+
line-height: 1;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.synod-fb-icon-btn:hover { color: #f1f5f9; }
|
|
1033
|
+
|
|
1034
|
+
.synod-fb-queue-footer {
|
|
1035
|
+
display: flex;
|
|
1036
|
+
justify-content: space-between;
|
|
1037
|
+
align-items: center;
|
|
1038
|
+
padding: 12px 16px;
|
|
1039
|
+
border-top: 1px solid #334155;
|
|
1040
|
+
gap: 8px;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.synod-fb-btn-danger {
|
|
1044
|
+
padding: 6px 12px;
|
|
1045
|
+
border: 1px solid #475569;
|
|
1046
|
+
background: transparent;
|
|
1047
|
+
color: #dc2626;
|
|
1048
|
+
font-size: 12px;
|
|
1049
|
+
font-weight: 500;
|
|
1050
|
+
border-radius: 4px;
|
|
1051
|
+
cursor: pointer;
|
|
1052
|
+
font-family: inherit;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.synod-fb-btn-danger:hover { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
1056
|
+
|
|
1057
|
+
.synod-fb-queue-banner {
|
|
1058
|
+
padding: 8px 12px;
|
|
1059
|
+
margin: 8px;
|
|
1060
|
+
border-radius: 4px;
|
|
1061
|
+
font-size: 12px;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.synod-fb-queue-banner.error {
|
|
1065
|
+
background: rgba(220, 38, 38, 0.15);
|
|
1066
|
+
color: #fca5a5;
|
|
1067
|
+
border: 1px solid rgba(220, 38, 38, 0.3);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.synod-fb-queue-banner.info {
|
|
1071
|
+
background: rgba(13, 148, 136, 0.15);
|
|
1072
|
+
color: #5eead4;
|
|
1073
|
+
border: 1px solid rgba(13, 148, 136, 0.3);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/* \u2500\u2500 Toasts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1077
|
+
.synod-fb-toast-container {
|
|
1078
|
+
position: absolute;
|
|
1079
|
+
bottom: 16px;
|
|
1080
|
+
left: 50%;
|
|
1081
|
+
transform: translateX(-50%);
|
|
1082
|
+
display: flex;
|
|
1083
|
+
flex-direction: column;
|
|
1084
|
+
gap: 8px;
|
|
1085
|
+
z-index: 100;
|
|
1086
|
+
pointer-events: none;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
.synod-fb-toast {
|
|
1090
|
+
max-width: 400px;
|
|
1091
|
+
min-height: 36px;
|
|
1092
|
+
padding: 8px 16px;
|
|
1093
|
+
border-radius: 6px;
|
|
1094
|
+
font-size: 12px;
|
|
1095
|
+
font-weight: 500;
|
|
1096
|
+
font-family: inherit;
|
|
1097
|
+
display: flex;
|
|
1098
|
+
align-items: center;
|
|
1099
|
+
gap: 6px;
|
|
1100
|
+
animation: synod-fb-toast-in 200ms ease-out;
|
|
1101
|
+
white-space: nowrap;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.synod-fb-toast-logo {
|
|
1105
|
+
display: flex;
|
|
1106
|
+
align-items: center;
|
|
1107
|
+
flex-shrink: 0;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
@keyframes synod-fb-toast-in {
|
|
1111
|
+
from { transform: translateY(20px); opacity: 0; }
|
|
1112
|
+
to { transform: translateY(0); opacity: 1; }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.synod-fb-toast.success { background: #065f46; color: #d1fae5; }
|
|
1116
|
+
.synod-fb-toast.error { background: #991b1b; color: #fee2e2; }
|
|
1117
|
+
.synod-fb-toast.info { background: #1e3a5f; color: #dbeafe; }
|
|
1118
|
+
|
|
1119
|
+
/* \u2500\u2500 Tooltip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1120
|
+
.synod-fb-tooltip {
|
|
1121
|
+
position: absolute;
|
|
1122
|
+
padding: 4px 10px;
|
|
1123
|
+
background: #0f172a;
|
|
1124
|
+
color: #94a3b8;
|
|
1125
|
+
font-size: 11px;
|
|
1126
|
+
border-radius: 4px;
|
|
1127
|
+
border: 1px solid #334155;
|
|
1128
|
+
white-space: nowrap;
|
|
1129
|
+
z-index: 5;
|
|
1130
|
+
pointer-events: none;
|
|
1131
|
+
animation: synod-fb-fade-in 150ms ease-out;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* \u2500\u2500 Focus-visible \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1135
|
+
button:focus-visible,
|
|
1136
|
+
input:focus-visible,
|
|
1137
|
+
textarea:focus-visible {
|
|
1138
|
+
outline: 2px solid #0d9488;
|
|
1139
|
+
outline-offset: 2px;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/* \u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1144
|
+
*, *::before, *::after {
|
|
1145
|
+
animation-duration: 0ms !important;
|
|
1146
|
+
animation-delay: 0ms !important;
|
|
1147
|
+
transition-duration: 0ms !important;
|
|
1148
|
+
}
|
|
1149
|
+
.synod-fb-queue {
|
|
1150
|
+
animation: none;
|
|
1151
|
+
opacity: 1;
|
|
1152
|
+
}
|
|
1153
|
+
.synod-fb-form {
|
|
1154
|
+
animation: none;
|
|
1155
|
+
opacity: 1;
|
|
1156
|
+
}
|
|
1157
|
+
.synod-fb-toast {
|
|
1158
|
+
animation: none;
|
|
1159
|
+
opacity: 1;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
`;
|
|
1163
|
+
|
|
1164
|
+
// src/ui/FeedbackOverlay.ts
|
|
1165
|
+
var HOST_ID = "synod-fb-host";
|
|
1166
|
+
var MAX_Z = 2147483647;
|
|
1167
|
+
var FeedbackOverlay = class {
|
|
1168
|
+
constructor() {
|
|
1169
|
+
this._host = null;
|
|
1170
|
+
this._shadowRoot = null;
|
|
1171
|
+
this._contentWrapper = null;
|
|
1172
|
+
}
|
|
1173
|
+
/** Whether the overlay is currently mounted. */
|
|
1174
|
+
get isMounted() {
|
|
1175
|
+
return this._host !== null && this._host.isConnected;
|
|
1176
|
+
}
|
|
1177
|
+
/** The shadow root (available only while mounted). */
|
|
1178
|
+
get root() {
|
|
1179
|
+
return this._shadowRoot;
|
|
1180
|
+
}
|
|
1181
|
+
/** The host element ID. */
|
|
1182
|
+
get hostId() {
|
|
1183
|
+
return HOST_ID;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Get the actual host DOM element (for contains() checks by the picker).
|
|
1187
|
+
* Returns null if not mounted.
|
|
1188
|
+
*/
|
|
1189
|
+
getHostElement() {
|
|
1190
|
+
return this._host;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Mount the overlay host to the given parent element (usually document.body).
|
|
1194
|
+
* Creates a closed shadow root, injects CSS, and sets up the content wrapper.
|
|
1195
|
+
*
|
|
1196
|
+
* Idempotent: if already mounted, returns without re-creating.
|
|
1197
|
+
*/
|
|
1198
|
+
mount(parent) {
|
|
1199
|
+
if (this.isMounted) return;
|
|
1200
|
+
const host = document.createElement("div");
|
|
1201
|
+
host.id = HOST_ID;
|
|
1202
|
+
host.style.cssText = `position: fixed; inset: 0; z-index: ${MAX_Z}; pointer-events: none;`;
|
|
1203
|
+
const shadowRoot = host.attachShadow({ mode: "closed" });
|
|
1204
|
+
const style = document.createElement("style");
|
|
1205
|
+
style.textContent = OVERLAY_CSS;
|
|
1206
|
+
shadowRoot.appendChild(style);
|
|
1207
|
+
const contentWrapper = document.createElement("div");
|
|
1208
|
+
contentWrapper.className = "synod-fb";
|
|
1209
|
+
shadowRoot.appendChild(contentWrapper);
|
|
1210
|
+
parent.appendChild(host);
|
|
1211
|
+
this._host = host;
|
|
1212
|
+
this._shadowRoot = shadowRoot;
|
|
1213
|
+
this._contentWrapper = contentWrapper;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Unmount the overlay: remove host element, null references.
|
|
1217
|
+
* Safe to call even if not mounted.
|
|
1218
|
+
*/
|
|
1219
|
+
unmount() {
|
|
1220
|
+
if (this._host && this._host.parentElement) {
|
|
1221
|
+
this._host.parentElement.removeChild(this._host);
|
|
1222
|
+
}
|
|
1223
|
+
this._host = null;
|
|
1224
|
+
this._shadowRoot = null;
|
|
1225
|
+
this._contentWrapper = null;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Get the content wrapper element inside the shadow root.
|
|
1229
|
+
* Children append their DOM nodes to this wrapper.
|
|
1230
|
+
*/
|
|
1231
|
+
getContentWrapper() {
|
|
1232
|
+
return this._contentWrapper;
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Check whether a given element is inside the overlay host.
|
|
1236
|
+
*/
|
|
1237
|
+
contains(element) {
|
|
1238
|
+
if (!element || !this._host) return false;
|
|
1239
|
+
return this._host === element || this._host.contains(element);
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
// src/utils/logo.ts
|
|
1244
|
+
var SYNOD_LOGO_SVG_MARKUP = `<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1245
|
+
<path d="M50 15 L72 52 H28 Z" fill="#36D6D6" />
|
|
1246
|
+
<path d="M50 15 L72 52 H28 Z" fill="#2DAFAF" transform="rotate(120 50 58)" />
|
|
1247
|
+
<path d="M50 15 L72 52 H28 Z" fill="#1F8F8F" transform="rotate(240 50 58)" />
|
|
1248
|
+
<circle cx="50" cy="58" r="4" fill="white" />
|
|
1249
|
+
</svg>`;
|
|
1250
|
+
function createSynodLogo(size) {
|
|
1251
|
+
const doc = new DOMParser().parseFromString(SYNOD_LOGO_SVG_MARKUP, "image/svg+xml");
|
|
1252
|
+
const svg = doc.documentElement;
|
|
1253
|
+
svg.setAttribute("width", String(size));
|
|
1254
|
+
svg.setAttribute("height", String(size));
|
|
1255
|
+
svg.setAttribute("aria-hidden", "true");
|
|
1256
|
+
return svg;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// src/interaction/Highlighter.ts
|
|
1260
|
+
var Highlighter = class {
|
|
1261
|
+
constructor(overlay2) {
|
|
1262
|
+
this._highlightEl = null;
|
|
1263
|
+
this._inspectorEl = null;
|
|
1264
|
+
this._logoEl = null;
|
|
1265
|
+
this._overlay = overlay2;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Show highlight at the target element's bounding rect.
|
|
1269
|
+
* Updates the inspector chip with tag, id/classes, and dimensions.
|
|
1270
|
+
*/
|
|
1271
|
+
highlight(element) {
|
|
1272
|
+
if (!element.isConnected) {
|
|
1273
|
+
this.clear();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const wrapper = this._overlay.getContentWrapper();
|
|
1277
|
+
if (!wrapper) return;
|
|
1278
|
+
if (!this._highlightEl) {
|
|
1279
|
+
this._highlightEl = document.createElement("div");
|
|
1280
|
+
this._highlightEl.className = "synod-fb-highlighter";
|
|
1281
|
+
wrapper.appendChild(this._highlightEl);
|
|
1282
|
+
}
|
|
1283
|
+
if (!this._inspectorEl) {
|
|
1284
|
+
this._inspectorEl = document.createElement("div");
|
|
1285
|
+
this._inspectorEl.className = "synod-fb-inspector";
|
|
1286
|
+
this._inspectorEl.setAttribute("aria-hidden", "true");
|
|
1287
|
+
wrapper.appendChild(this._inspectorEl);
|
|
1288
|
+
}
|
|
1289
|
+
if (!this._logoEl) {
|
|
1290
|
+
this._logoEl = document.createElement("div");
|
|
1291
|
+
this._logoEl.className = "synod-fb-highlighter-logo";
|
|
1292
|
+
this._logoEl.setAttribute("aria-hidden", "true");
|
|
1293
|
+
this._logoEl.appendChild(createSynodLogo(14));
|
|
1294
|
+
wrapper.appendChild(this._logoEl);
|
|
1295
|
+
}
|
|
1296
|
+
const rect = element.getBoundingClientRect();
|
|
1297
|
+
this._highlightEl.style.display = "block";
|
|
1298
|
+
this._highlightEl.style.left = `${rect.left}px`;
|
|
1299
|
+
this._highlightEl.style.top = `${rect.top}px`;
|
|
1300
|
+
this._highlightEl.style.width = `${rect.width}px`;
|
|
1301
|
+
this._highlightEl.style.height = `${rect.height}px`;
|
|
1302
|
+
const chipText = this.buildInspectorText(element, rect);
|
|
1303
|
+
this._inspectorEl.style.display = "block";
|
|
1304
|
+
this._inspectorEl.textContent = chipText;
|
|
1305
|
+
const chipTop = rect.top > 24 ? rect.top - 24 : rect.bottom + 4;
|
|
1306
|
+
this._inspectorEl.style.left = `${rect.left}px`;
|
|
1307
|
+
this._inspectorEl.style.top = `${chipTop}px`;
|
|
1308
|
+
this._logoEl.style.display = rect.width < 30 || rect.height < 30 ? "none" : "block";
|
|
1309
|
+
if (this._logoEl.style.display === "block") {
|
|
1310
|
+
this._logoEl.style.left = `${rect.left - 18}px`;
|
|
1311
|
+
this._logoEl.style.top = `${rect.top - 18}px`;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Clear the highlight overlay and inspector chip.
|
|
1316
|
+
*/
|
|
1317
|
+
clear() {
|
|
1318
|
+
if (this._highlightEl) {
|
|
1319
|
+
this._highlightEl.style.display = "none";
|
|
1320
|
+
}
|
|
1321
|
+
if (this._inspectorEl) {
|
|
1322
|
+
this._inspectorEl.style.display = "none";
|
|
1323
|
+
}
|
|
1324
|
+
if (this._logoEl) {
|
|
1325
|
+
this._logoEl.style.display = "none";
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Remove all DOM nodes created by this Highlighter.
|
|
1330
|
+
*/
|
|
1331
|
+
destroy() {
|
|
1332
|
+
this._highlightEl?.remove();
|
|
1333
|
+
this._inspectorEl?.remove();
|
|
1334
|
+
this._logoEl?.remove();
|
|
1335
|
+
this._highlightEl = null;
|
|
1336
|
+
this._inspectorEl = null;
|
|
1337
|
+
this._logoEl = null;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Build the inspector chip text: tagname + id/classes + dimensions.
|
|
1341
|
+
*/
|
|
1342
|
+
buildInspectorText(element, rect) {
|
|
1343
|
+
const tag = element.tagName.toLowerCase();
|
|
1344
|
+
let id = element.id ? `#${element.id}` : "";
|
|
1345
|
+
let classStr = "";
|
|
1346
|
+
if (element.classList.length > 0) {
|
|
1347
|
+
classStr = "." + Array.from(element.classList).join(".");
|
|
1348
|
+
if (classStr.length > 40) {
|
|
1349
|
+
classStr = classStr.substring(0, 37) + "\u2026";
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const identifier = id || classStr ? `${id}${classStr}` : tag;
|
|
1353
|
+
const dimensions = `${Math.round(rect.width)}\xD7${Math.round(rect.height)}`;
|
|
1354
|
+
return `${identifier} \xB7 ${dimensions}`;
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
// src/interaction/SelectionExtractor.ts
|
|
1359
|
+
function truncateText(text, max) {
|
|
1360
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
1361
|
+
if (trimmed.length <= max) return trimmed;
|
|
1362
|
+
return trimmed.substring(0, max) + "\u2026";
|
|
1363
|
+
}
|
|
1364
|
+
var NOISY_ATTRS = /* @__PURE__ */ new Set([
|
|
1365
|
+
"srcset",
|
|
1366
|
+
"data-srcset",
|
|
1367
|
+
"integrity",
|
|
1368
|
+
"onload",
|
|
1369
|
+
"onerror",
|
|
1370
|
+
"onclick",
|
|
1371
|
+
"onmouseover",
|
|
1372
|
+
"onsubmit"
|
|
1373
|
+
]);
|
|
1374
|
+
function extractAttributes(element) {
|
|
1375
|
+
const out = {};
|
|
1376
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
1377
|
+
const attr = element.attributes[i];
|
|
1378
|
+
const name = attr.name.toLowerCase();
|
|
1379
|
+
if (NOISY_ATTRS.has(name)) continue;
|
|
1380
|
+
const value = attr.value;
|
|
1381
|
+
if (value.length > 200) continue;
|
|
1382
|
+
out[name] = value;
|
|
1383
|
+
}
|
|
1384
|
+
return out;
|
|
1385
|
+
}
|
|
1386
|
+
function extractParentChain(element) {
|
|
1387
|
+
const chain = [];
|
|
1388
|
+
let current = element.parentElement;
|
|
1389
|
+
while (current && current.nodeType === 1) {
|
|
1390
|
+
const id = current.id || void 0;
|
|
1391
|
+
const cls = (current.getAttribute("class") ?? "").trim() || void 0;
|
|
1392
|
+
if (id || cls) {
|
|
1393
|
+
const entry = {
|
|
1394
|
+
tag: current.tagName.toLowerCase()
|
|
1395
|
+
};
|
|
1396
|
+
if (id) entry.id = id;
|
|
1397
|
+
if (cls) entry.className = cls;
|
|
1398
|
+
chain.unshift(entry);
|
|
1399
|
+
}
|
|
1400
|
+
if (current.tagName === "HTML") break;
|
|
1401
|
+
current = current.parentElement;
|
|
1402
|
+
}
|
|
1403
|
+
return chain;
|
|
1404
|
+
}
|
|
1405
|
+
function truncateHtml(html, max) {
|
|
1406
|
+
if (html.length <= max) return html;
|
|
1407
|
+
const slice = html.substring(0, max - 1);
|
|
1408
|
+
const lastLt = slice.lastIndexOf("<");
|
|
1409
|
+
const cleaned = lastLt > max - 30 && !slice.slice(lastLt).includes(">") ? slice.substring(0, lastLt) : slice;
|
|
1410
|
+
return cleaned + "\u2026";
|
|
1411
|
+
}
|
|
1412
|
+
function extract(element) {
|
|
1413
|
+
const rect = element.getBoundingClientRect();
|
|
1414
|
+
return {
|
|
1415
|
+
tagName: element.tagName.toLowerCase(),
|
|
1416
|
+
elementText: truncateText(element.textContent ?? "", 200),
|
|
1417
|
+
elementHtml: truncateHtml(element.outerHTML, 1500),
|
|
1418
|
+
elementAttributes: extractAttributes(element),
|
|
1419
|
+
parentChain: extractParentChain(element),
|
|
1420
|
+
boundingRect: {
|
|
1421
|
+
left: Math.round(rect.left),
|
|
1422
|
+
top: Math.round(rect.top),
|
|
1423
|
+
width: Math.round(rect.width),
|
|
1424
|
+
height: Math.round(rect.height)
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/interaction/ElementPicker.ts
|
|
1430
|
+
var ElementPicker = class {
|
|
1431
|
+
constructor(options) {
|
|
1432
|
+
this._controller = null;
|
|
1433
|
+
this._active = false;
|
|
1434
|
+
this._rafId = null;
|
|
1435
|
+
this._pendingEvent = null;
|
|
1436
|
+
// ── Event handlers ──────────────────────────────────────────────────
|
|
1437
|
+
this.onMouseMove = (e) => {
|
|
1438
|
+
this._pendingEvent = e;
|
|
1439
|
+
if (this._rafId !== null) return;
|
|
1440
|
+
this._rafId = requestAnimationFrame(() => {
|
|
1441
|
+
this._rafId = null;
|
|
1442
|
+
const evt = this._pendingEvent;
|
|
1443
|
+
if (!evt) return;
|
|
1444
|
+
const target = evt.target;
|
|
1445
|
+
if (!target || target.nodeType !== 1) return;
|
|
1446
|
+
if (this._options.overlayHostElement.contains(target)) return;
|
|
1447
|
+
if (target === document.body || target === document.documentElement) return;
|
|
1448
|
+
this._options.highlighter.highlight(target);
|
|
1449
|
+
});
|
|
1450
|
+
};
|
|
1451
|
+
this.onClick = (e) => {
|
|
1452
|
+
e.preventDefault();
|
|
1453
|
+
e.stopPropagation();
|
|
1454
|
+
const target = e.target;
|
|
1455
|
+
if (!target || target.nodeType !== 1) return;
|
|
1456
|
+
if (this._options.overlayHostElement.contains(target)) return;
|
|
1457
|
+
if (!target.isConnected) return;
|
|
1458
|
+
let context;
|
|
1459
|
+
try {
|
|
1460
|
+
context = extract(target);
|
|
1461
|
+
} catch {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
this._options.onElementClick(target, context);
|
|
1465
|
+
};
|
|
1466
|
+
this.onKeyDown = (e) => {
|
|
1467
|
+
if (e.key === "Escape") {
|
|
1468
|
+
e.preventDefault();
|
|
1469
|
+
e.stopPropagation();
|
|
1470
|
+
this._options.onEscape();
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
this.onScroll = () => {
|
|
1474
|
+
if (this._rafId !== null) return;
|
|
1475
|
+
this._rafId = requestAnimationFrame(() => {
|
|
1476
|
+
this._rafId = null;
|
|
1477
|
+
const evt = this._pendingEvent;
|
|
1478
|
+
if (!evt) return;
|
|
1479
|
+
const target = evt.target;
|
|
1480
|
+
if (target && target.isConnected && !this._options.overlayHostElement.contains(target)) {
|
|
1481
|
+
this._options.highlighter.highlight(target);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
};
|
|
1485
|
+
this._options = options;
|
|
1486
|
+
}
|
|
1487
|
+
get isActive() {
|
|
1488
|
+
return this._active;
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Activate the picker: attach capture-phase listeners to document.
|
|
1492
|
+
* No-op if already active (state machine guard).
|
|
1493
|
+
*/
|
|
1494
|
+
activate() {
|
|
1495
|
+
if (this._active) return;
|
|
1496
|
+
this._controller = new AbortController();
|
|
1497
|
+
this._active = true;
|
|
1498
|
+
const signal = this._controller.signal;
|
|
1499
|
+
document.addEventListener("mousemove", this.onMouseMove, { capture: true, signal });
|
|
1500
|
+
document.addEventListener("click", this.onClick, { capture: true, signal });
|
|
1501
|
+
document.addEventListener("keydown", this.onKeyDown, { capture: true, signal });
|
|
1502
|
+
document.addEventListener("scroll", this.onScroll, { capture: true, signal });
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Deactivate the picker: abort controller removes all listeners atomically.
|
|
1506
|
+
* Safe to call even if not active.
|
|
1507
|
+
*/
|
|
1508
|
+
deactivate() {
|
|
1509
|
+
if (!this._active) return;
|
|
1510
|
+
this._controller?.abort();
|
|
1511
|
+
this._controller = null;
|
|
1512
|
+
this._active = false;
|
|
1513
|
+
if (this._rafId !== null) {
|
|
1514
|
+
cancelAnimationFrame(this._rafId);
|
|
1515
|
+
this._rafId = null;
|
|
1516
|
+
}
|
|
1517
|
+
this._pendingEvent = null;
|
|
1518
|
+
this._options.highlighter.clear();
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// src/interaction/KeyComboDetector.ts
|
|
1523
|
+
var DEFAULT_HOLD_MS = 2e3;
|
|
1524
|
+
var DEFAULT_COMBO = {
|
|
1525
|
+
ctrl: true,
|
|
1526
|
+
shift: true,
|
|
1527
|
+
key: "e"
|
|
1528
|
+
};
|
|
1529
|
+
function parseCombo(comboStr) {
|
|
1530
|
+
if (!comboStr) return DEFAULT_COMBO;
|
|
1531
|
+
const lower = comboStr.toLowerCase();
|
|
1532
|
+
const parts = lower.split("+").map((p) => p.trim());
|
|
1533
|
+
const config = { key: "" };
|
|
1534
|
+
for (const part of parts) {
|
|
1535
|
+
switch (part) {
|
|
1536
|
+
case "ctrl":
|
|
1537
|
+
case "control":
|
|
1538
|
+
config.ctrl = true;
|
|
1539
|
+
break;
|
|
1540
|
+
case "shift":
|
|
1541
|
+
config.shift = true;
|
|
1542
|
+
break;
|
|
1543
|
+
case "alt":
|
|
1544
|
+
config.alt = true;
|
|
1545
|
+
break;
|
|
1546
|
+
default:
|
|
1547
|
+
config.key = part;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (!config.key) return DEFAULT_COMBO;
|
|
1551
|
+
return config;
|
|
1552
|
+
}
|
|
1553
|
+
var KeyComboDetector = class {
|
|
1554
|
+
constructor(comboStr, callbacks, holdMs = DEFAULT_HOLD_MS) {
|
|
1555
|
+
this._controller = null;
|
|
1556
|
+
// Charge state
|
|
1557
|
+
this._chargeStart = 0;
|
|
1558
|
+
this._charging = false;
|
|
1559
|
+
this._charged = false;
|
|
1560
|
+
this._rafId = null;
|
|
1561
|
+
// ── Handlers ────────────────────────────────────────────────────────
|
|
1562
|
+
this.onKeyDown = (e) => {
|
|
1563
|
+
if (!this.matchesCombo(e)) return;
|
|
1564
|
+
if (this._charging || this._charged) return;
|
|
1565
|
+
this._charging = true;
|
|
1566
|
+
this._charged = false;
|
|
1567
|
+
this._chargeStart = performance.now();
|
|
1568
|
+
this._callbacks.onChargeStart?.();
|
|
1569
|
+
this.tick();
|
|
1570
|
+
};
|
|
1571
|
+
this.onKeyUp = (e) => {
|
|
1572
|
+
if (!this.isComboKey(e.key.toLowerCase())) return;
|
|
1573
|
+
const wasCharging = this._charging;
|
|
1574
|
+
const wasCharged = this._charged;
|
|
1575
|
+
this.cancelRaf();
|
|
1576
|
+
this._charging = false;
|
|
1577
|
+
this._charged = false;
|
|
1578
|
+
this._chargeStart = 0;
|
|
1579
|
+
if (wasCharging || wasCharged) {
|
|
1580
|
+
this._callbacks.onChargeCancel?.();
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
this.tick = () => {
|
|
1584
|
+
if (!this._charging) return;
|
|
1585
|
+
const elapsed = performance.now() - this._chargeStart;
|
|
1586
|
+
const frac = Math.min(1, elapsed / this._holdMs);
|
|
1587
|
+
this._callbacks.onChargeProgress?.(frac);
|
|
1588
|
+
if (frac >= 1) {
|
|
1589
|
+
this._charging = false;
|
|
1590
|
+
this._charged = true;
|
|
1591
|
+
this.cancelRaf();
|
|
1592
|
+
this._callbacks.onCharged?.();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
this._rafId = requestAnimationFrame(this.tick);
|
|
1596
|
+
};
|
|
1597
|
+
this._combo = parseCombo(comboStr);
|
|
1598
|
+
this._holdMs = holdMs;
|
|
1599
|
+
this._callbacks = callbacks;
|
|
1600
|
+
}
|
|
1601
|
+
/** Start listening for the key combo. */
|
|
1602
|
+
start() {
|
|
1603
|
+
if (this._controller) return;
|
|
1604
|
+
this._controller = new AbortController();
|
|
1605
|
+
const signal = this._controller.signal;
|
|
1606
|
+
document.addEventListener("keydown", this.onKeyDown, { capture: true, signal });
|
|
1607
|
+
document.addEventListener("keyup", this.onKeyUp, { capture: true, signal });
|
|
1608
|
+
}
|
|
1609
|
+
/** Stop listening. Cancels any in-flight charge. */
|
|
1610
|
+
stop() {
|
|
1611
|
+
this._controller?.abort();
|
|
1612
|
+
this._controller = null;
|
|
1613
|
+
this.cancelRaf();
|
|
1614
|
+
this._charging = false;
|
|
1615
|
+
this._charged = false;
|
|
1616
|
+
this._chargeStart = 0;
|
|
1617
|
+
}
|
|
1618
|
+
/** Check if a KeyboardEvent matches the configured combo. */
|
|
1619
|
+
matchesCombo(e) {
|
|
1620
|
+
if (this._combo.ctrl && !e.ctrlKey) return false;
|
|
1621
|
+
if (this._combo.shift && !e.shiftKey) return false;
|
|
1622
|
+
if (this._combo.alt && !e.altKey) return false;
|
|
1623
|
+
return e.key.toLowerCase() === this._combo.key;
|
|
1624
|
+
}
|
|
1625
|
+
cancelRaf() {
|
|
1626
|
+
if (this._rafId !== null) {
|
|
1627
|
+
cancelAnimationFrame(this._rafId);
|
|
1628
|
+
this._rafId = null;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
isComboKey(key) {
|
|
1632
|
+
if (key === this._combo.key) return true;
|
|
1633
|
+
if (this._combo.ctrl && key === "control") return true;
|
|
1634
|
+
if (this._combo.shift && key === "shift") return true;
|
|
1635
|
+
if (this._combo.alt && key === "alt") return true;
|
|
1636
|
+
return false;
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
// src/ui/FloatingLogo.ts
|
|
1641
|
+
var STORAGE_KEY = "synod-feedback-logo-pos";
|
|
1642
|
+
var CLICK_THRESHOLD = 5;
|
|
1643
|
+
var FloatingLogo = class {
|
|
1644
|
+
constructor(_root, callbacks) {
|
|
1645
|
+
this._logo = null;
|
|
1646
|
+
this._badge = null;
|
|
1647
|
+
// Drag state
|
|
1648
|
+
this._dragging = false;
|
|
1649
|
+
this._startX = 0;
|
|
1650
|
+
this._startY = 0;
|
|
1651
|
+
this._logoX = 0;
|
|
1652
|
+
this._logoY = 0;
|
|
1653
|
+
this._moved = false;
|
|
1654
|
+
// ── Drag handlers ───────────────────────────────────────────────────
|
|
1655
|
+
this.onPointerDown = (e) => {
|
|
1656
|
+
if (!this._logo) return;
|
|
1657
|
+
e.preventDefault();
|
|
1658
|
+
e.stopPropagation();
|
|
1659
|
+
this._dragging = true;
|
|
1660
|
+
this._moved = false;
|
|
1661
|
+
this._startX = e.clientX;
|
|
1662
|
+
this._startY = e.clientY;
|
|
1663
|
+
try {
|
|
1664
|
+
this._logo.setPointerCapture(e.pointerId);
|
|
1665
|
+
} catch {
|
|
1666
|
+
}
|
|
1667
|
+
this._logo.addEventListener("pointermove", this.onPointerMove);
|
|
1668
|
+
this._logo.addEventListener("pointerup", this.onPointerUp);
|
|
1669
|
+
this._logo.addEventListener("pointercancel", this.onPointerUp);
|
|
1670
|
+
};
|
|
1671
|
+
this.onPointerMove = (e) => {
|
|
1672
|
+
if (!this._dragging || !this._logo) return;
|
|
1673
|
+
const dx = e.clientX - this._startX;
|
|
1674
|
+
const dy = e.clientY - this._startY;
|
|
1675
|
+
if (!this._moved && Math.abs(dx) + Math.abs(dy) > CLICK_THRESHOLD) {
|
|
1676
|
+
this._moved = true;
|
|
1677
|
+
}
|
|
1678
|
+
if (!this._moved) return;
|
|
1679
|
+
const newX = this._logoX + dx;
|
|
1680
|
+
const newY = this._logoY + dy;
|
|
1681
|
+
const clampedX = Math.max(8, Math.min(newX, window.innerWidth - 56));
|
|
1682
|
+
const clampedY = Math.max(8, Math.min(newY, window.innerHeight - 56));
|
|
1683
|
+
this._logo.style.left = `${clampedX}px`;
|
|
1684
|
+
this._logo.style.top = `${clampedY}px`;
|
|
1685
|
+
};
|
|
1686
|
+
this.onPointerUp = (e) => {
|
|
1687
|
+
if (!this._logo) return;
|
|
1688
|
+
this._dragging = false;
|
|
1689
|
+
try {
|
|
1690
|
+
this._logo.releasePointerCapture(e.pointerId);
|
|
1691
|
+
} catch {
|
|
1692
|
+
}
|
|
1693
|
+
this._logo.removeEventListener("pointermove", this.onPointerMove);
|
|
1694
|
+
this._logo.removeEventListener("pointerup", this.onPointerUp);
|
|
1695
|
+
this._logo.removeEventListener("pointercancel", this.onPointerUp);
|
|
1696
|
+
if (this._moved) {
|
|
1697
|
+
const left = parseFloat(this._logo.style.left || "0");
|
|
1698
|
+
const top = parseFloat(this._logo.style.top || "0");
|
|
1699
|
+
this._logoX = left;
|
|
1700
|
+
this._logoY = top;
|
|
1701
|
+
this.savePosition(left, top);
|
|
1702
|
+
} else {
|
|
1703
|
+
this._callbacks.onClick();
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
this.onKeyDown = (e) => {
|
|
1707
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1708
|
+
e.preventDefault();
|
|
1709
|
+
this._callbacks.onClick();
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
this.onContextMenu = (e) => {
|
|
1713
|
+
e.preventDefault();
|
|
1714
|
+
e.stopPropagation();
|
|
1715
|
+
this._callbacks.onContextMenu?.(e.clientX, e.clientY);
|
|
1716
|
+
};
|
|
1717
|
+
void _root;
|
|
1718
|
+
this._callbacks = callbacks;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Mount the logo into the shadow root content wrapper.
|
|
1722
|
+
*/
|
|
1723
|
+
mount(wrapper) {
|
|
1724
|
+
if (this._logo) return;
|
|
1725
|
+
const logo2 = document.createElement("div");
|
|
1726
|
+
logo2.className = "synod-fb-logo";
|
|
1727
|
+
logo2.appendChild(createSynodLogo(28));
|
|
1728
|
+
logo2.setAttribute("role", "button");
|
|
1729
|
+
logo2.setAttribute("aria-label", "Synod feedback \u2014 click to open queue");
|
|
1730
|
+
logo2.setAttribute("tabindex", "0");
|
|
1731
|
+
const pos = this.loadPosition();
|
|
1732
|
+
logo2.style.left = `${pos.x}px`;
|
|
1733
|
+
logo2.style.top = `${pos.y}px`;
|
|
1734
|
+
this._logoX = pos.x;
|
|
1735
|
+
this._logoY = pos.y;
|
|
1736
|
+
const badge = document.createElement("div");
|
|
1737
|
+
badge.className = "synod-fb-badge";
|
|
1738
|
+
badge.style.display = "none";
|
|
1739
|
+
badge.setAttribute("aria-live", "polite");
|
|
1740
|
+
logo2.appendChild(badge);
|
|
1741
|
+
logo2.addEventListener("pointerdown", this.onPointerDown);
|
|
1742
|
+
logo2.addEventListener("keydown", this.onKeyDown);
|
|
1743
|
+
logo2.addEventListener("contextmenu", this.onContextMenu);
|
|
1744
|
+
wrapper.appendChild(logo2);
|
|
1745
|
+
this._logo = logo2;
|
|
1746
|
+
this._badge = badge;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Remove the logo from the DOM.
|
|
1750
|
+
*/
|
|
1751
|
+
unmount() {
|
|
1752
|
+
this._logo?.removeEventListener("pointerdown", this.onPointerDown);
|
|
1753
|
+
this._logo?.removeEventListener("keydown", this.onKeyDown);
|
|
1754
|
+
this._logo?.removeEventListener("contextmenu", this.onContextMenu);
|
|
1755
|
+
this._logo?.remove();
|
|
1756
|
+
this._logo = null;
|
|
1757
|
+
this._badge = null;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Update the badge count.
|
|
1761
|
+
*/
|
|
1762
|
+
updateCount(count) {
|
|
1763
|
+
if (!this._badge || !this._logo) return;
|
|
1764
|
+
if (count > 0) {
|
|
1765
|
+
this._badge.style.display = "flex";
|
|
1766
|
+
this._badge.textContent = String(count);
|
|
1767
|
+
this._badge.setAttribute("aria-label", `${count} ${count === 1 ? "finding" : "findings"} queued`);
|
|
1768
|
+
this._badge.classList.remove("synod-fb-logo-badge-pulse");
|
|
1769
|
+
void this._badge.offsetWidth;
|
|
1770
|
+
this._badge.classList.add("synod-fb-logo-badge-pulse");
|
|
1771
|
+
} else {
|
|
1772
|
+
this._badge.style.display = "none";
|
|
1773
|
+
}
|
|
1774
|
+
this._callbacks.onCountChange?.(count);
|
|
1775
|
+
}
|
|
1776
|
+
// ── localStorage ────────────────────────────────────────────────────
|
|
1777
|
+
loadPosition() {
|
|
1778
|
+
try {
|
|
1779
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
1780
|
+
if (raw) {
|
|
1781
|
+
const pos = JSON.parse(raw);
|
|
1782
|
+
if (typeof pos.x === "number" && typeof pos.y === "number") {
|
|
1783
|
+
return pos;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
} catch {
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
x: Math.max(8, window.innerWidth - 64),
|
|
1790
|
+
y: Math.max(8, window.innerHeight - 64)
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
savePosition(x, y) {
|
|
1794
|
+
try {
|
|
1795
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ x, y }));
|
|
1796
|
+
} catch {
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
// src/utils/viewport.ts
|
|
1802
|
+
var MIN_MARGIN = 8;
|
|
1803
|
+
function calculateFlippedPosition(targetRect, elementSize, viewport, margin = MIN_MARGIN) {
|
|
1804
|
+
const rectBottom = targetRect.top + targetRect.height;
|
|
1805
|
+
const spaceBelow = viewport.innerHeight - rectBottom;
|
|
1806
|
+
let top;
|
|
1807
|
+
if (spaceBelow < elementSize.height + margin) {
|
|
1808
|
+
top = targetRect.top - elementSize.height - margin;
|
|
1809
|
+
} else {
|
|
1810
|
+
top = rectBottom + margin;
|
|
1811
|
+
}
|
|
1812
|
+
let left = targetRect.left;
|
|
1813
|
+
if (left + elementSize.width + margin > viewport.innerWidth) {
|
|
1814
|
+
left = viewport.innerWidth - elementSize.width - margin * 2;
|
|
1815
|
+
}
|
|
1816
|
+
top = Math.max(top, MIN_MARGIN);
|
|
1817
|
+
left = Math.max(left, MIN_MARGIN);
|
|
1818
|
+
return { left, top };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// src/utils/screenshot.ts
|
|
1822
|
+
var MAX_SCREENSHOT_BYTES = 8192;
|
|
1823
|
+
async function capture(element) {
|
|
1824
|
+
try {
|
|
1825
|
+
const mod = await import("modern-screenshot");
|
|
1826
|
+
const domToJpeg = mod.domToJpeg ?? mod.default?.domToJpeg;
|
|
1827
|
+
if (typeof domToJpeg !== "function") {
|
|
1828
|
+
console.warn("[synod-feedback] Screenshot: domToJpeg not found in modern-screenshot module");
|
|
1829
|
+
return null;
|
|
1830
|
+
}
|
|
1831
|
+
const dataUrl = await domToJpeg(element, { quality: 0.4, width: 200 });
|
|
1832
|
+
if (typeof dataUrl !== "string" || dataUrl.length === 0) {
|
|
1833
|
+
console.warn("[synod-feedback] Screenshot: domToJpeg returned invalid data");
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1836
|
+
if (dataUrl.length > MAX_SCREENSHOT_BYTES) {
|
|
1837
|
+
console.warn(
|
|
1838
|
+
`[synod-feedback] Screenshot: data URL too large (${dataUrl.length} bytes > ${MAX_SCREENSHOT_BYTES} limit), omitting`
|
|
1839
|
+
);
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1842
|
+
return dataUrl;
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
console.warn("[synod-feedback] Screenshot capture failed:", err);
|
|
1845
|
+
return null;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// src/ui/IssueForm.ts
|
|
1850
|
+
var FORM_WIDTH = 340;
|
|
1851
|
+
var IssueForm = class {
|
|
1852
|
+
constructor(wrapper, callbacks) {
|
|
1853
|
+
this._formEl = null;
|
|
1854
|
+
this._currentContext = null;
|
|
1855
|
+
this._screenshotDataUrl = null;
|
|
1856
|
+
this._screenshotElement = null;
|
|
1857
|
+
this._previouslyFocused = null;
|
|
1858
|
+
this._descTextarea = null;
|
|
1859
|
+
this._severity = "medium";
|
|
1860
|
+
this._screenshotToggle = null;
|
|
1861
|
+
this._screenshotLabel = null;
|
|
1862
|
+
this.onKeyDown = (e) => {
|
|
1863
|
+
if (e.key === "Escape") {
|
|
1864
|
+
e.preventDefault();
|
|
1865
|
+
e.stopPropagation();
|
|
1866
|
+
this._callbacks.onCancel();
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (e.key === "Tab" && this._formEl) {
|
|
1870
|
+
const focusable = this.getFocusableElements();
|
|
1871
|
+
if (focusable.length === 0) return;
|
|
1872
|
+
const first = focusable[0];
|
|
1873
|
+
const last = focusable[focusable.length - 1];
|
|
1874
|
+
if (e.shiftKey) {
|
|
1875
|
+
if (document.activeElement === first) {
|
|
1876
|
+
e.preventDefault();
|
|
1877
|
+
last.focus();
|
|
1878
|
+
}
|
|
1879
|
+
} else {
|
|
1880
|
+
if (document.activeElement === last) {
|
|
1881
|
+
e.preventDefault();
|
|
1882
|
+
first.focus();
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (e.ctrlKey && e.key === "Enter") {
|
|
1887
|
+
e.preventDefault();
|
|
1888
|
+
this.handleSubmit(e.shiftKey ? "send" : "add");
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
this._wrapper = wrapper;
|
|
1892
|
+
this._callbacks = callbacks;
|
|
1893
|
+
}
|
|
1894
|
+
show(context, element) {
|
|
1895
|
+
this.hide();
|
|
1896
|
+
this._currentContext = context;
|
|
1897
|
+
this._screenshotElement = element ?? null;
|
|
1898
|
+
this._screenshotDataUrl = null;
|
|
1899
|
+
this._severity = "medium";
|
|
1900
|
+
this._previouslyFocused = document.activeElement;
|
|
1901
|
+
const form = this.buildForm();
|
|
1902
|
+
this._formEl = form;
|
|
1903
|
+
const estimatedHeight = 380;
|
|
1904
|
+
const pos = calculateFlippedPosition(
|
|
1905
|
+
context.boundingRect,
|
|
1906
|
+
{ width: FORM_WIDTH, height: estimatedHeight },
|
|
1907
|
+
{ innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
|
1908
|
+
);
|
|
1909
|
+
form.style.left = `${pos.left}px`;
|
|
1910
|
+
form.style.top = `${pos.top}px`;
|
|
1911
|
+
this._wrapper.appendChild(form);
|
|
1912
|
+
requestAnimationFrame(() => {
|
|
1913
|
+
if (!this._formEl) return;
|
|
1914
|
+
const actualHeight = this._formEl.offsetHeight;
|
|
1915
|
+
if (Math.abs(actualHeight - estimatedHeight) > 20) {
|
|
1916
|
+
const adjustedPos = calculateFlippedPosition(
|
|
1917
|
+
context.boundingRect,
|
|
1918
|
+
{ width: FORM_WIDTH, height: actualHeight },
|
|
1919
|
+
{ innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
|
1920
|
+
);
|
|
1921
|
+
this._formEl.style.left = `${adjustedPos.left}px`;
|
|
1922
|
+
this._formEl.style.top = `${adjustedPos.top}px`;
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
requestAnimationFrame(() => {
|
|
1926
|
+
this._descTextarea?.focus();
|
|
1927
|
+
});
|
|
1928
|
+
form.addEventListener("keydown", this.onKeyDown);
|
|
1929
|
+
}
|
|
1930
|
+
hide() {
|
|
1931
|
+
this._formEl?.removeEventListener("keydown", this.onKeyDown);
|
|
1932
|
+
this._formEl?.remove();
|
|
1933
|
+
this._formEl = null;
|
|
1934
|
+
this._currentContext = null;
|
|
1935
|
+
this._screenshotDataUrl = null;
|
|
1936
|
+
this._screenshotElement = null;
|
|
1937
|
+
if (this._previouslyFocused && this._previouslyFocused.isConnected) {
|
|
1938
|
+
this._previouslyFocused.focus?.();
|
|
1939
|
+
}
|
|
1940
|
+
this._previouslyFocused = null;
|
|
1941
|
+
}
|
|
1942
|
+
get isVisible() {
|
|
1943
|
+
return this._formEl !== null && this._formEl.isConnected;
|
|
1944
|
+
}
|
|
1945
|
+
buildForm() {
|
|
1946
|
+
const form = document.createElement("div");
|
|
1947
|
+
form.className = "synod-fb-form";
|
|
1948
|
+
form.setAttribute("role", "dialog");
|
|
1949
|
+
form.setAttribute("aria-modal", "true");
|
|
1950
|
+
form.setAttribute("aria-label", "Describe issue");
|
|
1951
|
+
const header = document.createElement("div");
|
|
1952
|
+
header.className = "synod-fb-form-header";
|
|
1953
|
+
const headerLogo = document.createElement("div");
|
|
1954
|
+
headerLogo.className = "synod-fb-form-header-logo";
|
|
1955
|
+
headerLogo.appendChild(createSynodLogo(16));
|
|
1956
|
+
const headerText = document.createElement("div");
|
|
1957
|
+
headerText.className = "synod-fb-form-header-text";
|
|
1958
|
+
const ctx = this._currentContext;
|
|
1959
|
+
const id = ctx.elementAttributes?.["id"];
|
|
1960
|
+
const cls = ctx.elementAttributes?.["class"]?.trim();
|
|
1961
|
+
let descriptor = ctx.tagName;
|
|
1962
|
+
if (id) descriptor += `#${id}`;
|
|
1963
|
+
if (cls) descriptor += "." + cls.split(/\s+/).join(".");
|
|
1964
|
+
if (descriptor.length > 50) descriptor = descriptor.substring(0, 49) + "\u2026";
|
|
1965
|
+
headerText.textContent = `${descriptor} \xB7 ${ctx.boundingRect.width}\xD7${ctx.boundingRect.height}`;
|
|
1966
|
+
const closeBtn = document.createElement("button");
|
|
1967
|
+
closeBtn.className = "synod-fb-form-close";
|
|
1968
|
+
closeBtn.textContent = "\xD7";
|
|
1969
|
+
closeBtn.setAttribute("aria-label", "Cancel");
|
|
1970
|
+
closeBtn.addEventListener("click", () => this._callbacks.onCancel());
|
|
1971
|
+
header.appendChild(headerLogo);
|
|
1972
|
+
header.appendChild(headerText);
|
|
1973
|
+
header.appendChild(closeBtn);
|
|
1974
|
+
const body = document.createElement("div");
|
|
1975
|
+
body.className = "synod-fb-form-body";
|
|
1976
|
+
const descField = document.createElement("div");
|
|
1977
|
+
descField.className = "synod-fb-field";
|
|
1978
|
+
const descLabel = document.createElement("label");
|
|
1979
|
+
descLabel.className = "synod-fb-label";
|
|
1980
|
+
descLabel.textContent = "Description *";
|
|
1981
|
+
descLabel.setAttribute("for", "synod-fb-desc");
|
|
1982
|
+
const descTextarea = document.createElement("textarea");
|
|
1983
|
+
descTextarea.className = "synod-fb-textarea";
|
|
1984
|
+
descTextarea.id = "synod-fb-desc";
|
|
1985
|
+
descTextarea.rows = 3;
|
|
1986
|
+
descTextarea.maxLength = 2e3;
|
|
1987
|
+
descTextarea.setAttribute("aria-label", "Issue description");
|
|
1988
|
+
descTextarea.setAttribute("placeholder", "Describe what\u2019s wrong\u2026");
|
|
1989
|
+
descTextarea.setAttribute("required", "");
|
|
1990
|
+
this._descTextarea = descTextarea;
|
|
1991
|
+
descField.appendChild(descLabel);
|
|
1992
|
+
descField.appendChild(descTextarea);
|
|
1993
|
+
const sevField = document.createElement("div");
|
|
1994
|
+
sevField.className = "synod-fb-field";
|
|
1995
|
+
const sevLabel = document.createElement("div");
|
|
1996
|
+
sevLabel.className = "synod-fb-label";
|
|
1997
|
+
sevLabel.textContent = "Severity";
|
|
1998
|
+
const sevGroup = document.createElement("div");
|
|
1999
|
+
sevGroup.className = "synod-fb-severity-group";
|
|
2000
|
+
sevGroup.setAttribute("role", "group");
|
|
2001
|
+
sevGroup.setAttribute("aria-label", "Severity");
|
|
2002
|
+
const severities = ["low", "medium", "high"];
|
|
2003
|
+
for (const sev of severities) {
|
|
2004
|
+
const btn = document.createElement("button");
|
|
2005
|
+
btn.className = "synod-fb-sev-btn";
|
|
2006
|
+
btn.dataset.sev = sev;
|
|
2007
|
+
btn.textContent = sev.charAt(0).toUpperCase() + sev.slice(1);
|
|
2008
|
+
btn.setAttribute("aria-pressed", sev === this._severity ? "true" : "false");
|
|
2009
|
+
if (sev === this._severity) btn.classList.add("active");
|
|
2010
|
+
btn.addEventListener("click", (e) => {
|
|
2011
|
+
e.preventDefault();
|
|
2012
|
+
this._severity = sev;
|
|
2013
|
+
sevGroup.querySelectorAll(".synod-fb-sev-btn").forEach((b) => {
|
|
2014
|
+
const btnSev = b.dataset.sev;
|
|
2015
|
+
b.setAttribute("aria-pressed", btnSev === sev ? "true" : "false");
|
|
2016
|
+
b.classList.toggle("active", btnSev === sev);
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
btn.addEventListener("keydown", (e) => {
|
|
2020
|
+
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
2021
|
+
e.preventDefault();
|
|
2022
|
+
const btns = Array.from(sevGroup.querySelectorAll(".synod-fb-sev-btn"));
|
|
2023
|
+
const idx = btns.indexOf(e.currentTarget);
|
|
2024
|
+
const nextIdx = e.key === "ArrowRight" ? (idx + 1) % btns.length : (idx - 1 + btns.length) % btns.length;
|
|
2025
|
+
btns[nextIdx].click();
|
|
2026
|
+
btns[nextIdx].focus();
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
sevGroup.appendChild(btn);
|
|
2030
|
+
}
|
|
2031
|
+
sevField.appendChild(sevLabel);
|
|
2032
|
+
sevField.appendChild(sevGroup);
|
|
2033
|
+
const ssField = document.createElement("div");
|
|
2034
|
+
ssField.className = "synod-fb-field";
|
|
2035
|
+
const ssToggle = document.createElement("label");
|
|
2036
|
+
ssToggle.className = "synod-fb-screenshot-toggle";
|
|
2037
|
+
const ssCheckbox = document.createElement("input");
|
|
2038
|
+
ssCheckbox.type = "checkbox";
|
|
2039
|
+
ssCheckbox.addEventListener("change", () => this.onScreenshotToggle(ssCheckbox.checked));
|
|
2040
|
+
this._screenshotToggle = ssCheckbox;
|
|
2041
|
+
const ssLabel = document.createElement("span");
|
|
2042
|
+
ssLabel.textContent = "\u{1F4F7} Include screenshot";
|
|
2043
|
+
this._screenshotLabel = ssToggle;
|
|
2044
|
+
ssToggle.appendChild(ssCheckbox);
|
|
2045
|
+
ssToggle.appendChild(ssLabel);
|
|
2046
|
+
ssField.appendChild(ssToggle);
|
|
2047
|
+
const actions = document.createElement("div");
|
|
2048
|
+
actions.className = "synod-fb-form-actions";
|
|
2049
|
+
const addBtn = document.createElement("button");
|
|
2050
|
+
addBtn.className = "synod-fb-btn-secondary";
|
|
2051
|
+
addBtn.textContent = "+ Add to tray";
|
|
2052
|
+
addBtn.addEventListener("click", (e) => {
|
|
2053
|
+
e.preventDefault();
|
|
2054
|
+
this.handleSubmit("add");
|
|
2055
|
+
});
|
|
2056
|
+
const sendBtn = document.createElement("button");
|
|
2057
|
+
sendBtn.className = "synod-fb-btn-primary";
|
|
2058
|
+
sendBtn.textContent = "Send now";
|
|
2059
|
+
sendBtn.addEventListener("click", (e) => {
|
|
2060
|
+
e.preventDefault();
|
|
2061
|
+
this.handleSubmit("send");
|
|
2062
|
+
});
|
|
2063
|
+
actions.appendChild(addBtn);
|
|
2064
|
+
actions.appendChild(sendBtn);
|
|
2065
|
+
body.appendChild(descField);
|
|
2066
|
+
body.appendChild(sevField);
|
|
2067
|
+
body.appendChild(ssField);
|
|
2068
|
+
body.appendChild(actions);
|
|
2069
|
+
form.appendChild(header);
|
|
2070
|
+
form.appendChild(body);
|
|
2071
|
+
return form;
|
|
2072
|
+
}
|
|
2073
|
+
async onScreenshotToggle(checked) {
|
|
2074
|
+
if (!checked) {
|
|
2075
|
+
this._screenshotDataUrl = null;
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (!this._screenshotElement) {
|
|
2079
|
+
this.markScreenshotUnavailable();
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (this._screenshotLabel) {
|
|
2083
|
+
const labelSpan = this._screenshotLabel.querySelector("span");
|
|
2084
|
+
if (labelSpan) labelSpan.textContent = "\u{1F4F7} Capturing\u2026";
|
|
2085
|
+
}
|
|
2086
|
+
const dataUrl = await capture(this._screenshotElement);
|
|
2087
|
+
if (dataUrl) {
|
|
2088
|
+
this._screenshotDataUrl = dataUrl;
|
|
2089
|
+
if (this._screenshotLabel) {
|
|
2090
|
+
const labelSpan = this._screenshotLabel.querySelector("span");
|
|
2091
|
+
if (labelSpan) labelSpan.textContent = "\u{1F4F7} Screenshot captured \u2713";
|
|
2092
|
+
}
|
|
2093
|
+
} else {
|
|
2094
|
+
this.markScreenshotUnavailable();
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
markScreenshotUnavailable() {
|
|
2098
|
+
this._screenshotDataUrl = null;
|
|
2099
|
+
if (this._screenshotToggle) {
|
|
2100
|
+
this._screenshotToggle.checked = false;
|
|
2101
|
+
this._screenshotToggle.disabled = true;
|
|
2102
|
+
}
|
|
2103
|
+
if (this._screenshotLabel) {
|
|
2104
|
+
this._screenshotLabel.classList.add("disabled", "unavailable");
|
|
2105
|
+
const labelSpan = this._screenshotLabel.querySelector("span");
|
|
2106
|
+
if (labelSpan) labelSpan.textContent = "\u26A0 Screenshot unavailable";
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
handleSubmit(action) {
|
|
2110
|
+
if (!this._currentContext || !this._descTextarea) return;
|
|
2111
|
+
const description = this._descTextarea.value.trim();
|
|
2112
|
+
if (!description) return;
|
|
2113
|
+
const contextJson = {
|
|
2114
|
+
element_text: this._currentContext.elementText,
|
|
2115
|
+
element_html: this._currentContext.elementHtml,
|
|
2116
|
+
element_attributes: this._currentContext.elementAttributes,
|
|
2117
|
+
parent_chain: this._currentContext.parentChain,
|
|
2118
|
+
url: typeof window !== "undefined" ? window.location.href : void 0,
|
|
2119
|
+
// Best-effort route — pathname + hash + search captures the SPA "screen"
|
|
2120
|
+
// without re-sending the origin (already in url).
|
|
2121
|
+
route: typeof window !== "undefined" ? window.location.pathname + window.location.search + window.location.hash : void 0,
|
|
2122
|
+
page_title: typeof document !== "undefined" ? document.title : void 0,
|
|
2123
|
+
viewport: typeof window !== "undefined" ? { width: window.innerWidth, height: window.innerHeight } : void 0,
|
|
2124
|
+
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
|
|
2125
|
+
...this._screenshotDataUrl ? { screenshot_data_url: this._screenshotDataUrl } : {}
|
|
2126
|
+
};
|
|
2127
|
+
const result = {
|
|
2128
|
+
description,
|
|
2129
|
+
severity: this._severity,
|
|
2130
|
+
contextJson
|
|
2131
|
+
};
|
|
2132
|
+
if (action === "add") {
|
|
2133
|
+
this._callbacks.onAddToTray(result);
|
|
2134
|
+
} else {
|
|
2135
|
+
this._callbacks.onSendNow(result);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
getFocusableElements() {
|
|
2139
|
+
if (!this._formEl) return [];
|
|
2140
|
+
return Array.from(
|
|
2141
|
+
this._formEl.querySelectorAll(
|
|
2142
|
+
'input:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
2143
|
+
)
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
|
|
2148
|
+
// src/utils/title.ts
|
|
2149
|
+
function isTitlePending(title) {
|
|
2150
|
+
return !title || title.trim() === "";
|
|
2151
|
+
}
|
|
2152
|
+
function truncDesc(description) {
|
|
2153
|
+
const trimmed = (description ?? "").trim().replace(/\s+/g, " ");
|
|
2154
|
+
if (trimmed.length <= 60) return trimmed;
|
|
2155
|
+
const slice = trimmed.slice(0, 57);
|
|
2156
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
2157
|
+
const head = lastSpace > 25 ? slice.slice(0, lastSpace) : slice;
|
|
2158
|
+
return head + "\u2026";
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/utils/clipboard.ts
|
|
2162
|
+
var PREFIX = "synod-fb-v1:";
|
|
2163
|
+
var FORMAT_VERSION = 1;
|
|
2164
|
+
function withoutScreenshot(payload) {
|
|
2165
|
+
if (!payload.context_json?.screenshot_data_url) return payload;
|
|
2166
|
+
const { screenshot_data_url: _drop, ...rest } = payload.context_json;
|
|
2167
|
+
return { ...payload, context_json: rest };
|
|
2168
|
+
}
|
|
2169
|
+
function encodeBase64(unicodeStr) {
|
|
2170
|
+
const utf8 = new TextEncoder().encode(unicodeStr);
|
|
2171
|
+
let binary = "";
|
|
2172
|
+
for (const b of utf8) binary += String.fromCharCode(b);
|
|
2173
|
+
const standard = btoa(binary);
|
|
2174
|
+
return standard.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2175
|
+
}
|
|
2176
|
+
function decodeBase64(urlSafe) {
|
|
2177
|
+
const standard = urlSafe.replace(/-/g, "+").replace(/_/g, "/");
|
|
2178
|
+
const padded = standard + "=".repeat((4 - standard.length % 4) % 4);
|
|
2179
|
+
const binary = atob(padded);
|
|
2180
|
+
const bytes = new Uint8Array(binary.length);
|
|
2181
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2182
|
+
return new TextDecoder().decode(bytes);
|
|
2183
|
+
}
|
|
2184
|
+
function encodeFindingsToClipboard(findings) {
|
|
2185
|
+
const stripped = findings.map(withoutScreenshot);
|
|
2186
|
+
const payload = { v: FORMAT_VERSION, findings: stripped };
|
|
2187
|
+
const json = JSON.stringify(payload);
|
|
2188
|
+
return PREFIX + encodeBase64(json);
|
|
2189
|
+
}
|
|
2190
|
+
function decodeFindingsFromClipboard(raw) {
|
|
2191
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
2192
|
+
return { ok: false, error: "Empty clipboard" };
|
|
2193
|
+
}
|
|
2194
|
+
const trimmed = raw.trim();
|
|
2195
|
+
if (!trimmed.startsWith(PREFIX)) {
|
|
2196
|
+
return { ok: false, error: "Not a Synod findings blob (missing prefix)" };
|
|
2197
|
+
}
|
|
2198
|
+
const b64 = trimmed.slice(PREFIX.length);
|
|
2199
|
+
let json;
|
|
2200
|
+
try {
|
|
2201
|
+
json = decodeBase64(b64);
|
|
2202
|
+
} catch {
|
|
2203
|
+
return { ok: false, error: "Malformed base64" };
|
|
2204
|
+
}
|
|
2205
|
+
let parsed;
|
|
2206
|
+
try {
|
|
2207
|
+
parsed = JSON.parse(json);
|
|
2208
|
+
} catch {
|
|
2209
|
+
return { ok: false, error: "Malformed JSON payload" };
|
|
2210
|
+
}
|
|
2211
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2212
|
+
return { ok: false, error: "Payload is not an object" };
|
|
2213
|
+
}
|
|
2214
|
+
const obj = parsed;
|
|
2215
|
+
if (obj.v !== FORMAT_VERSION) {
|
|
2216
|
+
return { ok: false, error: `Unsupported format version ${obj.v}` };
|
|
2217
|
+
}
|
|
2218
|
+
if (!Array.isArray(obj.findings)) {
|
|
2219
|
+
return { ok: false, error: "Payload.findings is not an array" };
|
|
2220
|
+
}
|
|
2221
|
+
const validSeverities = ["low", "medium", "high"];
|
|
2222
|
+
const findings = [];
|
|
2223
|
+
for (let i = 0; i < obj.findings.length; i++) {
|
|
2224
|
+
const f = obj.findings[i];
|
|
2225
|
+
if (typeof f?.description !== "string" || f.description.trim() === "") {
|
|
2226
|
+
return { ok: false, error: `Finding[${i}] missing description` };
|
|
2227
|
+
}
|
|
2228
|
+
if (typeof f.severity !== "string" || !validSeverities.includes(f.severity)) {
|
|
2229
|
+
return { ok: false, error: `Finding[${i}] has invalid severity "${f.severity}"` };
|
|
2230
|
+
}
|
|
2231
|
+
if (!f.context_json || typeof f.context_json !== "object") {
|
|
2232
|
+
return { ok: false, error: `Finding[${i}] missing context_json` };
|
|
2233
|
+
}
|
|
2234
|
+
findings.push({
|
|
2235
|
+
title: typeof f.title === "string" ? f.title : "",
|
|
2236
|
+
description: f.description,
|
|
2237
|
+
severity: f.severity,
|
|
2238
|
+
context_json: f.context_json,
|
|
2239
|
+
...f.suggested_action ? { suggested_action: f.suggested_action } : {}
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
return { ok: true, findings };
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// src/ui/QueuePanel.ts
|
|
2246
|
+
var QueuePanel = class {
|
|
2247
|
+
constructor(wrapper, callbacks) {
|
|
2248
|
+
this._panelEl = null;
|
|
2249
|
+
this._bodyEl = null;
|
|
2250
|
+
this._bannerEl = null;
|
|
2251
|
+
this._sendBtn = null;
|
|
2252
|
+
this._isOpen = false;
|
|
2253
|
+
this._toastContainer = null;
|
|
2254
|
+
this._wrapper = wrapper;
|
|
2255
|
+
this._callbacks = callbacks;
|
|
2256
|
+
}
|
|
2257
|
+
get isOpen() {
|
|
2258
|
+
return this._isOpen;
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Open the queue panel drawer.
|
|
2262
|
+
*/
|
|
2263
|
+
show() {
|
|
2264
|
+
if (this._isOpen) return;
|
|
2265
|
+
const panel = this.buildPanel();
|
|
2266
|
+
this._panelEl = panel;
|
|
2267
|
+
this._wrapper.appendChild(panel);
|
|
2268
|
+
this._isOpen = true;
|
|
2269
|
+
this.renderItems();
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Close the queue panel drawer.
|
|
2273
|
+
*/
|
|
2274
|
+
hide() {
|
|
2275
|
+
this._panelEl?.remove();
|
|
2276
|
+
this._panelEl = null;
|
|
2277
|
+
this._bodyEl = null;
|
|
2278
|
+
this._bannerEl = null;
|
|
2279
|
+
this._sendBtn = null;
|
|
2280
|
+
this._isOpen = false;
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Refresh item list (call after queue changes).
|
|
2284
|
+
*/
|
|
2285
|
+
refresh() {
|
|
2286
|
+
if (!this._isOpen) return;
|
|
2287
|
+
this.renderItems();
|
|
2288
|
+
}
|
|
2289
|
+
// ── Build DOM ───────────────────────────────────────────────────────
|
|
2290
|
+
buildPanel() {
|
|
2291
|
+
const panel = document.createElement("div");
|
|
2292
|
+
panel.className = "synod-fb-queue";
|
|
2293
|
+
panel.setAttribute("role", "dialog");
|
|
2294
|
+
panel.setAttribute("aria-modal", "true");
|
|
2295
|
+
panel.setAttribute("aria-label", "Pending findings review");
|
|
2296
|
+
const header = document.createElement("div");
|
|
2297
|
+
header.className = "synod-fb-queue-header";
|
|
2298
|
+
const headerLogo = document.createElement("div");
|
|
2299
|
+
headerLogo.className = "synod-fb-queue-header-logo";
|
|
2300
|
+
headerLogo.appendChild(createSynodLogo(16));
|
|
2301
|
+
const title = document.createElement("div");
|
|
2302
|
+
title.className = "synod-fb-queue-title";
|
|
2303
|
+
title.textContent = "Pending Findings";
|
|
2304
|
+
const closeBtn = document.createElement("button");
|
|
2305
|
+
closeBtn.className = "synod-fb-icon-btn";
|
|
2306
|
+
closeBtn.textContent = "\xD7";
|
|
2307
|
+
closeBtn.setAttribute("aria-label", "Close queue panel");
|
|
2308
|
+
closeBtn.addEventListener("click", () => this.hide());
|
|
2309
|
+
header.appendChild(headerLogo);
|
|
2310
|
+
header.appendChild(title);
|
|
2311
|
+
header.appendChild(closeBtn);
|
|
2312
|
+
const banner = document.createElement("div");
|
|
2313
|
+
banner.className = "synod-fb-queue-banner";
|
|
2314
|
+
banner.style.display = "none";
|
|
2315
|
+
this._bannerEl = banner;
|
|
2316
|
+
const body = document.createElement("div");
|
|
2317
|
+
body.className = "synod-fb-queue-body";
|
|
2318
|
+
this._bodyEl = body;
|
|
2319
|
+
const footer = document.createElement("div");
|
|
2320
|
+
footer.className = "synod-fb-queue-footer";
|
|
2321
|
+
const clearBtn = document.createElement("button");
|
|
2322
|
+
clearBtn.className = "synod-fb-btn-danger";
|
|
2323
|
+
clearBtn.textContent = "Clear all";
|
|
2324
|
+
clearBtn.addEventListener("click", () => this.handleClearAll());
|
|
2325
|
+
const copyBtn = document.createElement("button");
|
|
2326
|
+
copyBtn.className = "synod-fb-icon-btn";
|
|
2327
|
+
copyBtn.setAttribute("aria-label", "Copy all findings to clipboard");
|
|
2328
|
+
copyBtn.title = "Copy to clipboard (offline transfer)";
|
|
2329
|
+
copyBtn.textContent = "\u29C9";
|
|
2330
|
+
copyBtn.addEventListener("click", () => this.handleCopyToClipboard());
|
|
2331
|
+
const sendBtn = document.createElement("button");
|
|
2332
|
+
sendBtn.className = "synod-fb-btn-primary";
|
|
2333
|
+
sendBtn.textContent = "Send batch";
|
|
2334
|
+
sendBtn.addEventListener("click", () => this.handleSendBatch());
|
|
2335
|
+
this._sendBtn = sendBtn;
|
|
2336
|
+
footer.appendChild(clearBtn);
|
|
2337
|
+
footer.appendChild(copyBtn);
|
|
2338
|
+
footer.appendChild(sendBtn);
|
|
2339
|
+
panel.appendChild(header);
|
|
2340
|
+
panel.appendChild(banner);
|
|
2341
|
+
panel.appendChild(body);
|
|
2342
|
+
panel.appendChild(footer);
|
|
2343
|
+
panel.addEventListener("keydown", (e) => {
|
|
2344
|
+
if (e.key === "Escape") {
|
|
2345
|
+
e.preventDefault();
|
|
2346
|
+
this.hide();
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
return panel;
|
|
2350
|
+
}
|
|
2351
|
+
renderItems() {
|
|
2352
|
+
if (!this._bodyEl) return;
|
|
2353
|
+
const items = this._callbacks.getQueueSnapshot();
|
|
2354
|
+
this._bodyEl.innerHTML = "";
|
|
2355
|
+
if (items.length === 0) {
|
|
2356
|
+
const empty = document.createElement("div");
|
|
2357
|
+
empty.className = "synod-fb-queue-empty";
|
|
2358
|
+
empty.textContent = "No findings queued yet. Click an element to start capturing.";
|
|
2359
|
+
this._bodyEl.appendChild(empty);
|
|
2360
|
+
if (this._sendBtn) {
|
|
2361
|
+
this._sendBtn.textContent = "Send batch";
|
|
2362
|
+
this._sendBtn.disabled = true;
|
|
2363
|
+
}
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
if (this._sendBtn) {
|
|
2367
|
+
this._sendBtn.textContent = `Send batch (${items.length})`;
|
|
2368
|
+
this._sendBtn.disabled = false;
|
|
2369
|
+
}
|
|
2370
|
+
items.forEach((item, index) => {
|
|
2371
|
+
const row = this.buildItemRow(item, index);
|
|
2372
|
+
this._bodyEl.appendChild(row);
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
buildItemRow(item, index) {
|
|
2376
|
+
const row = document.createElement("div");
|
|
2377
|
+
row.className = "synod-fb-queue-item";
|
|
2378
|
+
row.dataset.index = String(index);
|
|
2379
|
+
const thumb = document.createElement("div");
|
|
2380
|
+
thumb.className = "synod-fb-queue-item-thumb";
|
|
2381
|
+
if (item.context_json.screenshot_data_url) {
|
|
2382
|
+
const img = document.createElement("img");
|
|
2383
|
+
img.src = item.context_json.screenshot_data_url;
|
|
2384
|
+
img.alt = "Screenshot";
|
|
2385
|
+
thumb.appendChild(img);
|
|
2386
|
+
}
|
|
2387
|
+
const content = document.createElement("div");
|
|
2388
|
+
content.className = "synod-fb-queue-item-content";
|
|
2389
|
+
const titleEl = document.createElement("div");
|
|
2390
|
+
titleEl.className = "synod-fb-queue-item-title";
|
|
2391
|
+
if (isTitlePending(item.title)) {
|
|
2392
|
+
titleEl.classList.add("synod-fb-pending");
|
|
2393
|
+
titleEl.textContent = truncDesc(item.description) || "(untitled)";
|
|
2394
|
+
} else {
|
|
2395
|
+
titleEl.textContent = item.title;
|
|
2396
|
+
}
|
|
2397
|
+
const metaEl = document.createElement("div");
|
|
2398
|
+
metaEl.className = "synod-fb-queue-item-meta";
|
|
2399
|
+
const selectorSnippet = (item.context_json.css_selector || "").substring(0, 40);
|
|
2400
|
+
metaEl.textContent = `${selectorSnippet} \xB7 ${item.severity}`;
|
|
2401
|
+
content.appendChild(titleEl);
|
|
2402
|
+
content.appendChild(metaEl);
|
|
2403
|
+
const actions = document.createElement("div");
|
|
2404
|
+
actions.className = "synod-fb-queue-item-actions";
|
|
2405
|
+
const editBtn = document.createElement("button");
|
|
2406
|
+
editBtn.className = "synod-fb-icon-btn";
|
|
2407
|
+
editBtn.textContent = "\u270E";
|
|
2408
|
+
editBtn.setAttribute("aria-label", `Edit finding ${index + 1}`);
|
|
2409
|
+
editBtn.addEventListener("click", (e) => {
|
|
2410
|
+
e.stopPropagation();
|
|
2411
|
+
this.toggleEditRow(row, item, index);
|
|
2412
|
+
});
|
|
2413
|
+
const deleteBtn = document.createElement("button");
|
|
2414
|
+
deleteBtn.className = "synod-fb-icon-btn";
|
|
2415
|
+
deleteBtn.textContent = "\u2715";
|
|
2416
|
+
deleteBtn.setAttribute("aria-label", `Delete finding ${index + 1}`);
|
|
2417
|
+
deleteBtn.addEventListener("click", (e) => {
|
|
2418
|
+
e.stopPropagation();
|
|
2419
|
+
this._callbacks.onRemoveItem(index);
|
|
2420
|
+
this.refresh();
|
|
2421
|
+
});
|
|
2422
|
+
actions.appendChild(editBtn);
|
|
2423
|
+
actions.appendChild(deleteBtn);
|
|
2424
|
+
row.appendChild(thumb);
|
|
2425
|
+
row.appendChild(content);
|
|
2426
|
+
row.appendChild(actions);
|
|
2427
|
+
return row;
|
|
2428
|
+
}
|
|
2429
|
+
toggleEditRow(row, item, index) {
|
|
2430
|
+
const existingEdit = row.querySelector(".synod-fb-edit-form");
|
|
2431
|
+
if (existingEdit) {
|
|
2432
|
+
existingEdit.remove();
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
const editForm = document.createElement("div");
|
|
2436
|
+
editForm.className = "synod-fb-edit-form";
|
|
2437
|
+
editForm.style.padding = "8px 0 4px";
|
|
2438
|
+
const titleInput = document.createElement("input");
|
|
2439
|
+
titleInput.className = "synod-fb-input";
|
|
2440
|
+
titleInput.value = item.title;
|
|
2441
|
+
titleInput.style.marginBottom = "4px";
|
|
2442
|
+
const descInput = document.createElement("textarea");
|
|
2443
|
+
descInput.className = "synod-fb-textarea";
|
|
2444
|
+
descInput.value = item.description;
|
|
2445
|
+
descInput.rows = 2;
|
|
2446
|
+
descInput.style.marginBottom = "4px";
|
|
2447
|
+
const sevGroup = document.createElement("div");
|
|
2448
|
+
sevGroup.className = "synod-fb-severity-group";
|
|
2449
|
+
sevGroup.setAttribute("role", "group");
|
|
2450
|
+
let editSeverity = item.severity;
|
|
2451
|
+
const severities = ["low", "medium", "high"];
|
|
2452
|
+
for (const sev of severities) {
|
|
2453
|
+
const btn = document.createElement("button");
|
|
2454
|
+
btn.className = "synod-fb-sev-btn";
|
|
2455
|
+
btn.dataset.sev = sev;
|
|
2456
|
+
btn.textContent = sev.charAt(0).toUpperCase() + sev.slice(1);
|
|
2457
|
+
btn.setAttribute("aria-pressed", sev === editSeverity ? "true" : "false");
|
|
2458
|
+
if (sev === editSeverity) btn.classList.add("active");
|
|
2459
|
+
btn.addEventListener("click", (e) => {
|
|
2460
|
+
e.preventDefault();
|
|
2461
|
+
editSeverity = sev;
|
|
2462
|
+
sevGroup.querySelectorAll(".synod-fb-sev-btn").forEach((b) => {
|
|
2463
|
+
const bSev = b.dataset.sev;
|
|
2464
|
+
b.setAttribute("aria-pressed", bSev === sev ? "true" : "false");
|
|
2465
|
+
b.classList.toggle("active", bSev === sev);
|
|
2466
|
+
});
|
|
2467
|
+
});
|
|
2468
|
+
sevGroup.appendChild(btn);
|
|
2469
|
+
}
|
|
2470
|
+
const saveBtn = document.createElement("button");
|
|
2471
|
+
saveBtn.className = "synod-fb-btn-primary";
|
|
2472
|
+
saveBtn.textContent = "Save";
|
|
2473
|
+
saveBtn.style.marginTop = "4px";
|
|
2474
|
+
saveBtn.addEventListener("click", (e) => {
|
|
2475
|
+
e.preventDefault();
|
|
2476
|
+
this._callbacks.onEditItem(index, titleInput.value.trim(), descInput.value.trim(), editSeverity);
|
|
2477
|
+
this.refresh();
|
|
2478
|
+
});
|
|
2479
|
+
editForm.appendChild(titleInput);
|
|
2480
|
+
editForm.appendChild(descInput);
|
|
2481
|
+
editForm.appendChild(sevGroup);
|
|
2482
|
+
editForm.appendChild(saveBtn);
|
|
2483
|
+
row.appendChild(editForm);
|
|
2484
|
+
titleInput.focus();
|
|
2485
|
+
}
|
|
2486
|
+
// ── Actions ─────────────────────────────────────────────────────────
|
|
2487
|
+
async handleSendBatch() {
|
|
2488
|
+
if (!this._sendBtn) return;
|
|
2489
|
+
const originalText = this._sendBtn.textContent;
|
|
2490
|
+
this._sendBtn.disabled = true;
|
|
2491
|
+
this._sendBtn.textContent = "Sending\u2026";
|
|
2492
|
+
this.hideBanner();
|
|
2493
|
+
if (this._bodyEl) {
|
|
2494
|
+
this._bodyEl.style.opacity = "0.5";
|
|
2495
|
+
this._bodyEl.style.pointerEvents = "none";
|
|
2496
|
+
}
|
|
2497
|
+
try {
|
|
2498
|
+
const result = await this._callbacks.onSendBatch();
|
|
2499
|
+
this.handleSendResult(result);
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
this.handleSendError(err);
|
|
2502
|
+
} finally {
|
|
2503
|
+
if (this._bodyEl) {
|
|
2504
|
+
this._bodyEl.style.opacity = "";
|
|
2505
|
+
this._bodyEl.style.pointerEvents = "";
|
|
2506
|
+
}
|
|
2507
|
+
if (this._sendBtn) {
|
|
2508
|
+
this._sendBtn.disabled = false;
|
|
2509
|
+
this._sendBtn.textContent = originalText || "Send batch";
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
handleSendResult(result) {
|
|
2514
|
+
const total = result.created.length + result.errors.length;
|
|
2515
|
+
if (result.errors.length === 0 && result.created.length > 0) {
|
|
2516
|
+
this.showToast("success", `\u2713 ${result.created.length} ${result.created.length === 1 ? "finding" : "findings"} sent to Synod`);
|
|
2517
|
+
setTimeout(() => this.hide(), 1e3);
|
|
2518
|
+
} else if (result.created.length > 0 && result.errors.length > 0) {
|
|
2519
|
+
this.showBanner("info", `${result.created.length} of ${total} findings sent. ${result.errors.length} failed.`);
|
|
2520
|
+
this.showToast("info", `${result.created.length} of ${total} findings sent`);
|
|
2521
|
+
} else if (result.errors.length > 0 && result.created.length === 0) {
|
|
2522
|
+
const errMsg = result.errors.map((e) => e.error).join("; ");
|
|
2523
|
+
this.showBanner("error", `\u26A0 Send failed: ${errMsg}. Your findings are saved \u2014 try again.`);
|
|
2524
|
+
}
|
|
2525
|
+
this.refresh();
|
|
2526
|
+
}
|
|
2527
|
+
handleSendError(err) {
|
|
2528
|
+
const isClientErr = "code" in err;
|
|
2529
|
+
const code = isClientErr ? err.code : null;
|
|
2530
|
+
if (code === "AUTH_ERROR") {
|
|
2531
|
+
this.showBanner(
|
|
2532
|
+
"error",
|
|
2533
|
+
"\u26A0 Token invalid or expired. Regenerate the token in Synod Desktop Settings \u2192 Extensions."
|
|
2534
|
+
);
|
|
2535
|
+
} else {
|
|
2536
|
+
const msg = err.message || "Unknown error";
|
|
2537
|
+
this.showBanner("error", `\u26A0 Send failed: ${msg}. Your findings are saved \u2014 try again.`);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
async handleCopyToClipboard() {
|
|
2541
|
+
const items = this._callbacks.getQueueSnapshot();
|
|
2542
|
+
if (items.length === 0) {
|
|
2543
|
+
this.showToast("info", "No findings to copy");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const encoded = encodeFindingsToClipboard(items);
|
|
2547
|
+
try {
|
|
2548
|
+
let ok = false;
|
|
2549
|
+
if (this._callbacks.onCopyToClipboard) {
|
|
2550
|
+
ok = await this._callbacks.onCopyToClipboard(items);
|
|
2551
|
+
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
|
|
2552
|
+
await navigator.clipboard.writeText(encoded);
|
|
2553
|
+
ok = true;
|
|
2554
|
+
}
|
|
2555
|
+
if (ok) {
|
|
2556
|
+
this.showToast(
|
|
2557
|
+
"success",
|
|
2558
|
+
`\u2713 Copied ${items.length} ${items.length === 1 ? "finding" : "findings"} (screenshots stripped)`
|
|
2559
|
+
);
|
|
2560
|
+
} else {
|
|
2561
|
+
this.showToast("error", "\u26A0 Clipboard unavailable in this browser");
|
|
2562
|
+
}
|
|
2563
|
+
} catch {
|
|
2564
|
+
this.showToast("error", "\u26A0 Copy failed \u2014 try Send batch instead");
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
handleClearAll() {
|
|
2568
|
+
const items = this._callbacks.getQueueSnapshot();
|
|
2569
|
+
if (items.length > 3) {
|
|
2570
|
+
if (!window.confirm(`Clear all ${items.length} findings?`)) return;
|
|
2571
|
+
}
|
|
2572
|
+
this._callbacks.onClearAll();
|
|
2573
|
+
this.refresh();
|
|
2574
|
+
}
|
|
2575
|
+
// ── Banner ──────────────────────────────────────────────────────────
|
|
2576
|
+
showBanner(type, message) {
|
|
2577
|
+
if (!this._bannerEl) return;
|
|
2578
|
+
this._bannerEl.className = `synod-fb-queue-banner ${type}`;
|
|
2579
|
+
this._bannerEl.textContent = message;
|
|
2580
|
+
this._bannerEl.style.display = "block";
|
|
2581
|
+
}
|
|
2582
|
+
hideBanner() {
|
|
2583
|
+
if (!this._bannerEl) return;
|
|
2584
|
+
this._bannerEl.style.display = "none";
|
|
2585
|
+
}
|
|
2586
|
+
// ── Toast ───────────────────────────────────────────────────────────
|
|
2587
|
+
showToast(variant, message) {
|
|
2588
|
+
if (!this._toastContainer) {
|
|
2589
|
+
this._toastContainer = document.createElement("div");
|
|
2590
|
+
this._toastContainer.className = "synod-fb-toast-container";
|
|
2591
|
+
this._wrapper.appendChild(this._toastContainer);
|
|
2592
|
+
}
|
|
2593
|
+
const toast = document.createElement("div");
|
|
2594
|
+
toast.className = `synod-fb-toast ${variant}`;
|
|
2595
|
+
toast.setAttribute("role", variant === "error" ? "alert" : "status");
|
|
2596
|
+
toast.setAttribute("aria-live", variant === "error" ? "assertive" : "polite");
|
|
2597
|
+
const toastLogo = document.createElement("span");
|
|
2598
|
+
toastLogo.className = "synod-fb-toast-logo";
|
|
2599
|
+
toastLogo.appendChild(createSynodLogo(14));
|
|
2600
|
+
const toastMsg = document.createElement("span");
|
|
2601
|
+
toastMsg.textContent = message;
|
|
2602
|
+
toast.appendChild(toastLogo);
|
|
2603
|
+
toast.appendChild(toastMsg);
|
|
2604
|
+
this._toastContainer.appendChild(toast);
|
|
2605
|
+
while (this._toastContainer.children.length > 3) {
|
|
2606
|
+
this._toastContainer.removeChild(this._toastContainer.firstChild);
|
|
2607
|
+
}
|
|
2608
|
+
const duration = variant === "error" ? 6e3 : variant === "success" ? 4e3 : 3e3;
|
|
2609
|
+
setTimeout(() => {
|
|
2610
|
+
toast.remove();
|
|
2611
|
+
}, duration);
|
|
2612
|
+
}
|
|
2613
|
+
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
2614
|
+
destroy() {
|
|
2615
|
+
this.hide();
|
|
2616
|
+
this._toastContainer?.remove();
|
|
2617
|
+
this._toastContainer = null;
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
|
|
2621
|
+
// src/ui/ChargeIndicator.ts
|
|
2622
|
+
var RING_RADIUS = 18;
|
|
2623
|
+
var RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
|
2624
|
+
var ChargeIndicator = class {
|
|
2625
|
+
constructor() {
|
|
2626
|
+
this._root = null;
|
|
2627
|
+
this._circle = null;
|
|
2628
|
+
this._mounted = false;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Build the indicator's DOM once and append it to the given wrapper.
|
|
2632
|
+
* The indicator starts hidden; call showAt() to make it visible.
|
|
2633
|
+
*/
|
|
2634
|
+
mount(wrapper) {
|
|
2635
|
+
if (this._mounted) return;
|
|
2636
|
+
const root = document.createElement("div");
|
|
2637
|
+
root.className = "synod-fb-charge";
|
|
2638
|
+
root.setAttribute("aria-hidden", "true");
|
|
2639
|
+
root.style.display = "none";
|
|
2640
|
+
const svgNS = "http://www.w3.org/2000/svg";
|
|
2641
|
+
const svg = document.createElementNS(svgNS, "svg");
|
|
2642
|
+
svg.setAttribute("class", "synod-fb-charge-ring");
|
|
2643
|
+
svg.setAttribute("viewBox", "0 0 40 40");
|
|
2644
|
+
svg.setAttribute("width", "40");
|
|
2645
|
+
svg.setAttribute("height", "40");
|
|
2646
|
+
const bgCircle = document.createElementNS(svgNS, "circle");
|
|
2647
|
+
bgCircle.setAttribute("cx", "20");
|
|
2648
|
+
bgCircle.setAttribute("cy", "20");
|
|
2649
|
+
bgCircle.setAttribute("r", String(RING_RADIUS));
|
|
2650
|
+
bgCircle.setAttribute("class", "synod-fb-charge-ring-bg");
|
|
2651
|
+
const fgCircle = document.createElementNS(svgNS, "circle");
|
|
2652
|
+
fgCircle.setAttribute("cx", "20");
|
|
2653
|
+
fgCircle.setAttribute("cy", "20");
|
|
2654
|
+
fgCircle.setAttribute("r", String(RING_RADIUS));
|
|
2655
|
+
fgCircle.setAttribute("class", "synod-fb-charge-ring-fg");
|
|
2656
|
+
fgCircle.setAttribute("stroke-dasharray", String(RING_CIRCUMFERENCE));
|
|
2657
|
+
fgCircle.setAttribute("stroke-dashoffset", String(RING_CIRCUMFERENCE));
|
|
2658
|
+
svg.appendChild(bgCircle);
|
|
2659
|
+
svg.appendChild(fgCircle);
|
|
2660
|
+
root.appendChild(svg);
|
|
2661
|
+
root.appendChild(createSynodLogo(22));
|
|
2662
|
+
wrapper.appendChild(root);
|
|
2663
|
+
this._root = root;
|
|
2664
|
+
this._circle = fgCircle;
|
|
2665
|
+
this._mounted = true;
|
|
2666
|
+
}
|
|
2667
|
+
/** Whether mount() has been called and the indicator is in the DOM. */
|
|
2668
|
+
get isMounted() {
|
|
2669
|
+
return this._mounted && this._root !== null && this._root.isConnected;
|
|
2670
|
+
}
|
|
2671
|
+
/** Display the indicator at viewport-relative coordinates. */
|
|
2672
|
+
showAt(x, y) {
|
|
2673
|
+
if (!this._root) return;
|
|
2674
|
+
this._root.style.left = `${x}px`;
|
|
2675
|
+
this._root.style.top = `${y}px`;
|
|
2676
|
+
this._root.style.display = "flex";
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Update the ring's progress.
|
|
2680
|
+
* @param fraction 0..1 — clamped. At 1 the .charged class is added.
|
|
2681
|
+
*/
|
|
2682
|
+
setProgress(fraction) {
|
|
2683
|
+
if (!this._root || !this._circle) return;
|
|
2684
|
+
const clamped = Math.max(0, Math.min(1, fraction));
|
|
2685
|
+
const offset = RING_CIRCUMFERENCE * (1 - clamped);
|
|
2686
|
+
this._circle.setAttribute("stroke-dashoffset", String(offset));
|
|
2687
|
+
if (clamped >= 1) {
|
|
2688
|
+
this._root.classList.add("charged");
|
|
2689
|
+
} else {
|
|
2690
|
+
this._root.classList.remove("charged");
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
/** Hide the indicator. Safe to call when already hidden. */
|
|
2694
|
+
hide() {
|
|
2695
|
+
if (!this._root) return;
|
|
2696
|
+
this._root.style.display = "none";
|
|
2697
|
+
this._root.classList.remove("charged");
|
|
2698
|
+
if (this._circle) {
|
|
2699
|
+
this._circle.setAttribute(
|
|
2700
|
+
"stroke-dashoffset",
|
|
2701
|
+
String(RING_CIRCUMFERENCE)
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
/** Remove the indicator from the DOM and release references. */
|
|
2706
|
+
destroy() {
|
|
2707
|
+
if (this._root && this._root.parentElement) {
|
|
2708
|
+
this._root.parentElement.removeChild(this._root);
|
|
2709
|
+
}
|
|
2710
|
+
this._root = null;
|
|
2711
|
+
this._circle = null;
|
|
2712
|
+
this._mounted = false;
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
// src/ui/LogoContextMenu.ts
|
|
2717
|
+
var LogoContextMenu = class {
|
|
2718
|
+
constructor(callbacks) {
|
|
2719
|
+
this._root = null;
|
|
2720
|
+
this._outsideClickListener = null;
|
|
2721
|
+
this._escapeListener = null;
|
|
2722
|
+
this._dismissListener = null;
|
|
2723
|
+
this._callbacks = callbacks;
|
|
2724
|
+
}
|
|
2725
|
+
get isOpen() {
|
|
2726
|
+
return this._root !== null && this._root.isConnected;
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Show the menu at the given viewport coordinates.
|
|
2730
|
+
* If already open, the existing menu is closed first.
|
|
2731
|
+
*/
|
|
2732
|
+
showAt(wrapper, x, y) {
|
|
2733
|
+
this.hide();
|
|
2734
|
+
const root = document.createElement("div");
|
|
2735
|
+
root.className = "synod-fb-context-menu";
|
|
2736
|
+
root.setAttribute("role", "menu");
|
|
2737
|
+
root.setAttribute("aria-label", "Synod overlay options");
|
|
2738
|
+
const menuWidth = 180;
|
|
2739
|
+
const menuHeight = 40;
|
|
2740
|
+
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
|
2741
|
+
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
|
2742
|
+
root.style.left = `${Math.max(8, clampedX)}px`;
|
|
2743
|
+
root.style.top = `${Math.max(8, clampedY)}px`;
|
|
2744
|
+
const hideItem = document.createElement("button");
|
|
2745
|
+
hideItem.type = "button";
|
|
2746
|
+
hideItem.className = "synod-fb-context-menu-item";
|
|
2747
|
+
hideItem.setAttribute("role", "menuitem");
|
|
2748
|
+
hideItem.textContent = "Hide Synod overlay";
|
|
2749
|
+
hideItem.addEventListener("click", () => {
|
|
2750
|
+
this.hide();
|
|
2751
|
+
this._callbacks.onHide();
|
|
2752
|
+
});
|
|
2753
|
+
root.appendChild(hideItem);
|
|
2754
|
+
wrapper.appendChild(root);
|
|
2755
|
+
this._root = root;
|
|
2756
|
+
this._outsideClickListener = (e) => {
|
|
2757
|
+
if (!this._root) return;
|
|
2758
|
+
if (e.target instanceof Node && this._root.contains(e.target)) return;
|
|
2759
|
+
this.hide();
|
|
2760
|
+
};
|
|
2761
|
+
setTimeout(() => {
|
|
2762
|
+
if (!this._root) return;
|
|
2763
|
+
document.addEventListener("click", this._outsideClickListener, true);
|
|
2764
|
+
document.addEventListener("contextmenu", this._outsideClickListener, true);
|
|
2765
|
+
}, 0);
|
|
2766
|
+
this._escapeListener = (e) => {
|
|
2767
|
+
if (e.key === "Escape") {
|
|
2768
|
+
e.stopPropagation();
|
|
2769
|
+
this.hide();
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
document.addEventListener("keydown", this._escapeListener, true);
|
|
2773
|
+
this._dismissListener = () => this.hide();
|
|
2774
|
+
window.addEventListener("scroll", this._dismissListener, true);
|
|
2775
|
+
window.addEventListener("resize", this._dismissListener, true);
|
|
2776
|
+
}
|
|
2777
|
+
hide() {
|
|
2778
|
+
if (this._outsideClickListener) {
|
|
2779
|
+
document.removeEventListener("click", this._outsideClickListener, true);
|
|
2780
|
+
document.removeEventListener("contextmenu", this._outsideClickListener, true);
|
|
2781
|
+
this._outsideClickListener = null;
|
|
2782
|
+
}
|
|
2783
|
+
if (this._escapeListener) {
|
|
2784
|
+
document.removeEventListener("keydown", this._escapeListener, true);
|
|
2785
|
+
this._escapeListener = null;
|
|
2786
|
+
}
|
|
2787
|
+
if (this._dismissListener) {
|
|
2788
|
+
window.removeEventListener("scroll", this._dismissListener, true);
|
|
2789
|
+
window.removeEventListener("resize", this._dismissListener, true);
|
|
2790
|
+
this._dismissListener = null;
|
|
2791
|
+
}
|
|
2792
|
+
this._root?.remove();
|
|
2793
|
+
this._root = null;
|
|
2794
|
+
}
|
|
2795
|
+
destroy() {
|
|
2796
|
+
this.hide();
|
|
2797
|
+
}
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
// src/interaction/setup.ts
|
|
2801
|
+
var manager = null;
|
|
2802
|
+
var overlay = null;
|
|
2803
|
+
var highlighter = null;
|
|
2804
|
+
var picker = null;
|
|
2805
|
+
var comboDetector = null;
|
|
2806
|
+
var logo = null;
|
|
2807
|
+
var issueForm = null;
|
|
2808
|
+
var queuePanel = null;
|
|
2809
|
+
var chargeIndicator = null;
|
|
2810
|
+
var contextMenu = null;
|
|
2811
|
+
var state = "inactive";
|
|
2812
|
+
var unregisterEnable = null;
|
|
2813
|
+
var unregisterDisable = null;
|
|
2814
|
+
var lastSelectionContext = null;
|
|
2815
|
+
var lastMouseX = 0;
|
|
2816
|
+
var lastMouseY = 0;
|
|
2817
|
+
var mouseMoveListener = null;
|
|
2818
|
+
function __resetInteractionLayer() {
|
|
2819
|
+
if (unregisterEnable) unregisterEnable();
|
|
2820
|
+
if (unregisterDisable) unregisterDisable();
|
|
2821
|
+
unregisterEnable = null;
|
|
2822
|
+
unregisterDisable = null;
|
|
2823
|
+
manager = null;
|
|
2824
|
+
overlay = null;
|
|
2825
|
+
highlighter = null;
|
|
2826
|
+
picker = null;
|
|
2827
|
+
comboDetector = null;
|
|
2828
|
+
logo = null;
|
|
2829
|
+
issueForm = null;
|
|
2830
|
+
queuePanel = null;
|
|
2831
|
+
chargeIndicator = null;
|
|
2832
|
+
contextMenu = null;
|
|
2833
|
+
state = "inactive";
|
|
2834
|
+
lastSelectionContext = null;
|
|
2835
|
+
lastMouseX = 0;
|
|
2836
|
+
lastMouseY = 0;
|
|
2837
|
+
mouseMoveListener = null;
|
|
2838
|
+
}
|
|
2839
|
+
function buildFinding(description, severity, context, screenshotDataUrl) {
|
|
2840
|
+
return {
|
|
2841
|
+
title: "",
|
|
2842
|
+
description,
|
|
2843
|
+
severity,
|
|
2844
|
+
// Gateway handler (crates/core/src/handlers/external_findings.rs:42)
|
|
2845
|
+
// requires suggested_action as a non-optional String and validates it
|
|
2846
|
+
// against ["create_story", "create_research", "create_hotfix"]. Omitting
|
|
2847
|
+
// it makes the whole batch return 422.
|
|
2848
|
+
suggested_action: "create_story",
|
|
2849
|
+
context_json: {
|
|
2850
|
+
element_text: context.elementText,
|
|
2851
|
+
element_html: context.elementHtml,
|
|
2852
|
+
element_attributes: context.elementAttributes,
|
|
2853
|
+
parent_chain: context.parentChain,
|
|
2854
|
+
url: typeof window !== "undefined" ? window.location.href : void 0,
|
|
2855
|
+
// Best-effort route — pathname + hash + search captures the SPA "screen"
|
|
2856
|
+
// without re-sending the origin (already in url).
|
|
2857
|
+
route: typeof window !== "undefined" ? window.location.pathname + window.location.search + window.location.hash : void 0,
|
|
2858
|
+
page_title: typeof document !== "undefined" ? document.title : void 0,
|
|
2859
|
+
viewport: typeof window !== "undefined" ? { width: window.innerWidth, height: window.innerHeight } : void 0,
|
|
2860
|
+
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
|
|
2861
|
+
...screenshotDataUrl ? { screenshot_data_url: screenshotDataUrl } : {}
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function registerInteractionLayer(fm) {
|
|
2866
|
+
if (unregisterEnable) return;
|
|
2867
|
+
manager = fm;
|
|
2868
|
+
unregisterEnable = manager.onEnable(() => {
|
|
2869
|
+
if (state !== "inactive") return;
|
|
2870
|
+
overlay = new FeedbackOverlay();
|
|
2871
|
+
overlay.mount(document.body);
|
|
2872
|
+
const wrapper = overlay.getContentWrapper();
|
|
2873
|
+
highlighter = new Highlighter(overlay);
|
|
2874
|
+
state = "idle";
|
|
2875
|
+
chargeIndicator = new ChargeIndicator();
|
|
2876
|
+
chargeIndicator.mount(wrapper);
|
|
2877
|
+
contextMenu = new LogoContextMenu({
|
|
2878
|
+
onHide: () => {
|
|
2879
|
+
hideVisibleUI();
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
mouseMoveListener = (e) => {
|
|
2883
|
+
lastMouseX = e.clientX;
|
|
2884
|
+
lastMouseY = e.clientY;
|
|
2885
|
+
};
|
|
2886
|
+
document.addEventListener("mousemove", mouseMoveListener);
|
|
2887
|
+
comboDetector = new KeyComboDetector(manager.config?.keyCombo, {
|
|
2888
|
+
onChargeStart: () => {
|
|
2889
|
+
if (state !== "idle") return;
|
|
2890
|
+
state = "charging";
|
|
2891
|
+
chargeIndicator?.showAt(lastMouseX, lastMouseY);
|
|
2892
|
+
},
|
|
2893
|
+
onChargeProgress: (frac) => {
|
|
2894
|
+
if (state !== "charging") return;
|
|
2895
|
+
chargeIndicator?.setProgress(frac);
|
|
2896
|
+
chargeIndicator?.showAt(lastMouseX, lastMouseY);
|
|
2897
|
+
},
|
|
2898
|
+
onCharged: () => {
|
|
2899
|
+
if (state !== "charging") return;
|
|
2900
|
+
chargeIndicator?.hide();
|
|
2901
|
+
ensureVisibleUI();
|
|
2902
|
+
activateSelection();
|
|
2903
|
+
},
|
|
2904
|
+
onChargeCancel: () => {
|
|
2905
|
+
if (state === "charging") {
|
|
2906
|
+
chargeIndicator?.hide();
|
|
2907
|
+
state = "idle";
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
});
|
|
2911
|
+
comboDetector.start();
|
|
2912
|
+
});
|
|
2913
|
+
unregisterDisable = manager.onDisable(() => {
|
|
2914
|
+
picker?.deactivate();
|
|
2915
|
+
comboDetector?.stop();
|
|
2916
|
+
queuePanel?.destroy();
|
|
2917
|
+
issueForm?.hide();
|
|
2918
|
+
highlighter?.destroy();
|
|
2919
|
+
logo?.unmount();
|
|
2920
|
+
chargeIndicator?.destroy();
|
|
2921
|
+
contextMenu?.destroy();
|
|
2922
|
+
if (mouseMoveListener) {
|
|
2923
|
+
document.removeEventListener("mousemove", mouseMoveListener);
|
|
2924
|
+
}
|
|
2925
|
+
overlay?.unmount();
|
|
2926
|
+
overlay = null;
|
|
2927
|
+
highlighter = null;
|
|
2928
|
+
picker = null;
|
|
2929
|
+
comboDetector = null;
|
|
2930
|
+
logo = null;
|
|
2931
|
+
issueForm = null;
|
|
2932
|
+
queuePanel = null;
|
|
2933
|
+
chargeIndicator = null;
|
|
2934
|
+
contextMenu = null;
|
|
2935
|
+
mouseMoveListener = null;
|
|
2936
|
+
state = "inactive";
|
|
2937
|
+
lastSelectionContext = null;
|
|
2938
|
+
lastMouseX = 0;
|
|
2939
|
+
lastMouseY = 0;
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
function ensureVisibleUI() {
|
|
2943
|
+
if (!overlay || !manager) return;
|
|
2944
|
+
const wrapper = overlay.getContentWrapper();
|
|
2945
|
+
if (!wrapper) return;
|
|
2946
|
+
if (!logo) {
|
|
2947
|
+
logo = new FloatingLogo(overlay.root, {
|
|
2948
|
+
onClick: () => {
|
|
2949
|
+
if (state === "idle" || state === "selecting") {
|
|
2950
|
+
toggleQueuePanel();
|
|
2951
|
+
}
|
|
2952
|
+
},
|
|
2953
|
+
onContextMenu: (x, y) => {
|
|
2954
|
+
showLogoContextMenu(x, y);
|
|
2955
|
+
}
|
|
2956
|
+
});
|
|
2957
|
+
logo.mount(wrapper);
|
|
2958
|
+
}
|
|
2959
|
+
if (!issueForm) {
|
|
2960
|
+
issueForm = new IssueForm(wrapper, {
|
|
2961
|
+
onAddToTray: (result) => {
|
|
2962
|
+
const ctx = lastSelectionContext;
|
|
2963
|
+
if (!ctx || !manager) return;
|
|
2964
|
+
const finding = buildFinding(
|
|
2965
|
+
result.description,
|
|
2966
|
+
result.severity,
|
|
2967
|
+
ctx,
|
|
2968
|
+
result.contextJson.screenshot_data_url ?? null
|
|
2969
|
+
);
|
|
2970
|
+
manager.queue(finding);
|
|
2971
|
+
updateBadgeCount();
|
|
2972
|
+
issueForm?.hide();
|
|
2973
|
+
deactivateSelection();
|
|
2974
|
+
},
|
|
2975
|
+
onSendNow: async (result) => {
|
|
2976
|
+
const ctx = lastSelectionContext;
|
|
2977
|
+
if (!ctx || !manager) return;
|
|
2978
|
+
const finding = buildFinding(
|
|
2979
|
+
result.description,
|
|
2980
|
+
result.severity,
|
|
2981
|
+
ctx,
|
|
2982
|
+
result.contextJson.screenshot_data_url ?? null
|
|
2983
|
+
);
|
|
2984
|
+
manager.queue(finding);
|
|
2985
|
+
issueForm?.hide();
|
|
2986
|
+
try {
|
|
2987
|
+
await manager.sendBatch();
|
|
2988
|
+
updateBadgeCount();
|
|
2989
|
+
deactivateSelection();
|
|
2990
|
+
} catch {
|
|
2991
|
+
updateBadgeCount();
|
|
2992
|
+
toggleQueuePanel();
|
|
2993
|
+
}
|
|
2994
|
+
},
|
|
2995
|
+
onCancel: () => {
|
|
2996
|
+
issueForm?.hide();
|
|
2997
|
+
deactivateSelection();
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
if (!queuePanel) {
|
|
3002
|
+
queuePanel = new QueuePanel(wrapper, {
|
|
3003
|
+
getQueueSnapshot: () => manager?.getQueueSnapshot() ?? [],
|
|
3004
|
+
onSendBatch: () => manager.sendBatch(),
|
|
3005
|
+
onClearAll: () => {
|
|
3006
|
+
manager?.clearQueue();
|
|
3007
|
+
updateBadgeCount();
|
|
3008
|
+
},
|
|
3009
|
+
onRemoveItem: (index) => {
|
|
3010
|
+
if (!manager) return;
|
|
3011
|
+
const snapshot = manager.getQueueSnapshot();
|
|
3012
|
+
const remaining = snapshot.filter((_, i) => i !== index);
|
|
3013
|
+
manager.replaceQueue(remaining);
|
|
3014
|
+
updateBadgeCount();
|
|
3015
|
+
},
|
|
3016
|
+
onEditItem: (index, title, description, severity) => {
|
|
3017
|
+
if (!manager) return;
|
|
3018
|
+
const snapshot = manager.getQueueSnapshot();
|
|
3019
|
+
if (index >= 0 && index < snapshot.length) {
|
|
3020
|
+
snapshot[index] = { ...snapshot[index], title, description, severity };
|
|
3021
|
+
manager.replaceQueue(snapshot);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
updateBadgeCount();
|
|
3027
|
+
}
|
|
3028
|
+
function hideVisibleUI() {
|
|
3029
|
+
if (state === "charging") {
|
|
3030
|
+
chargeIndicator?.hide();
|
|
3031
|
+
}
|
|
3032
|
+
picker?.deactivate();
|
|
3033
|
+
picker = null;
|
|
3034
|
+
highlighter?.clear();
|
|
3035
|
+
issueForm?.hide();
|
|
3036
|
+
queuePanel?.destroy();
|
|
3037
|
+
logo?.unmount();
|
|
3038
|
+
queuePanel = null;
|
|
3039
|
+
logo = null;
|
|
3040
|
+
state = "idle";
|
|
3041
|
+
}
|
|
3042
|
+
function showLogoContextMenu(x, y) {
|
|
3043
|
+
if (!overlay || !contextMenu) return;
|
|
3044
|
+
const wrapper = overlay.getContentWrapper();
|
|
3045
|
+
if (!wrapper) return;
|
|
3046
|
+
contextMenu.showAt(wrapper, x, y);
|
|
3047
|
+
}
|
|
3048
|
+
function activateSelection() {
|
|
3049
|
+
if (state !== "idle" && state !== "charging" || !overlay || !highlighter) return;
|
|
3050
|
+
const hostEl = overlay.getHostElement();
|
|
3051
|
+
if (!hostEl) return;
|
|
3052
|
+
if (picker) {
|
|
3053
|
+
picker.deactivate();
|
|
3054
|
+
}
|
|
3055
|
+
picker = new ElementPicker({
|
|
3056
|
+
overlayHostElement: hostEl,
|
|
3057
|
+
highlighter,
|
|
3058
|
+
onElementClick: (element, context) => {
|
|
3059
|
+
if (state !== "selecting") return;
|
|
3060
|
+
lastSelectionContext = context;
|
|
3061
|
+
state = "capturing";
|
|
3062
|
+
picker?.deactivate();
|
|
3063
|
+
issueForm?.show(context, element);
|
|
3064
|
+
},
|
|
3065
|
+
onEscape: () => {
|
|
3066
|
+
if (state === "selecting") {
|
|
3067
|
+
deactivateSelection();
|
|
3068
|
+
} else if (state === "capturing") {
|
|
3069
|
+
issueForm?.hide();
|
|
3070
|
+
deactivateSelection();
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
});
|
|
3074
|
+
picker.activate();
|
|
3075
|
+
state = "selecting";
|
|
3076
|
+
}
|
|
3077
|
+
function deactivateSelection() {
|
|
3078
|
+
picker?.deactivate();
|
|
3079
|
+
highlighter?.clear();
|
|
3080
|
+
state = "idle";
|
|
3081
|
+
}
|
|
3082
|
+
function toggleQueuePanel() {
|
|
3083
|
+
if (!queuePanel) return;
|
|
3084
|
+
if (queuePanel.isOpen) {
|
|
3085
|
+
queuePanel.hide();
|
|
3086
|
+
} else {
|
|
3087
|
+
queuePanel.show();
|
|
3088
|
+
queuePanel.refresh();
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function updateBadgeCount() {
|
|
3092
|
+
logo?.updateCount(manager?.getQueueSnapshot().length ?? 0);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// src/index.ts
|
|
3096
|
+
var manager2 = new FeedbackManager();
|
|
3097
|
+
registerInteractionLayer(manager2);
|
|
3098
|
+
function init(config) {
|
|
3099
|
+
manager2.init(config);
|
|
3100
|
+
manager2.enable();
|
|
3101
|
+
}
|
|
3102
|
+
function enable() {
|
|
3103
|
+
manager2.enable();
|
|
3104
|
+
}
|
|
3105
|
+
function disable() {
|
|
3106
|
+
manager2.disable();
|
|
3107
|
+
}
|
|
3108
|
+
function onEnable(handler) {
|
|
3109
|
+
return manager2.onEnable(handler);
|
|
3110
|
+
}
|
|
3111
|
+
function onDisable(handler) {
|
|
3112
|
+
return manager2.onDisable(handler);
|
|
3113
|
+
}
|
|
3114
|
+
async function sendBatch() {
|
|
3115
|
+
return manager2.sendBatch();
|
|
3116
|
+
}
|
|
3117
|
+
function queue(finding) {
|
|
3118
|
+
manager2.queue(finding);
|
|
3119
|
+
}
|
|
3120
|
+
function clearQueue() {
|
|
3121
|
+
manager2.clearQueue();
|
|
3122
|
+
}
|
|
3123
|
+
function replaceQueue(items) {
|
|
3124
|
+
manager2.replaceQueue(items);
|
|
3125
|
+
}
|
|
3126
|
+
function isEnabled() {
|
|
3127
|
+
return manager2.enabled;
|
|
3128
|
+
}
|
|
3129
|
+
function getQueueSnapshot() {
|
|
3130
|
+
return manager2.getQueueSnapshot();
|
|
3131
|
+
}
|
|
3132
|
+
function getQueueLength() {
|
|
3133
|
+
return manager2.queueLength;
|
|
3134
|
+
}
|
|
3135
|
+
function __resetForTesting() {
|
|
3136
|
+
__resetInteractionLayer();
|
|
3137
|
+
manager2.destroy();
|
|
3138
|
+
manager2 = new FeedbackManager();
|
|
3139
|
+
registerInteractionLayer(manager2);
|
|
3140
|
+
}
|
|
3141
|
+
export {
|
|
3142
|
+
FeedbackManager,
|
|
3143
|
+
SynodFeedbackClient,
|
|
3144
|
+
__resetForTesting,
|
|
3145
|
+
clearQueue,
|
|
3146
|
+
decodeFindingsFromClipboard,
|
|
3147
|
+
disable,
|
|
3148
|
+
enable,
|
|
3149
|
+
encodeFindingsToClipboard,
|
|
3150
|
+
getQueueLength,
|
|
3151
|
+
getQueueSnapshot,
|
|
3152
|
+
init,
|
|
3153
|
+
isEnabled,
|
|
3154
|
+
onDisable,
|
|
3155
|
+
onEnable,
|
|
3156
|
+
queue,
|
|
3157
|
+
replaceQueue,
|
|
3158
|
+
sendBatch
|
|
3159
|
+
};
|
|
3160
|
+
//# sourceMappingURL=index.mjs.map
|