@harness-fe/runtime 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/buffer.d.ts +13 -0
  4. package/dist/buffer.js +26 -0
  5. package/dist/capture.d.ts +47 -0
  6. package/dist/capture.js +112 -0
  7. package/dist/client.d.ts +82 -0
  8. package/dist/client.js +364 -0
  9. package/dist/commands.d.ts +10 -0
  10. package/dist/commands.js +304 -0
  11. package/dist/dashboardUrl.d.ts +18 -0
  12. package/dist/dashboardUrl.js +20 -0
  13. package/dist/fetchPatch.d.ts +39 -0
  14. package/dist/fetchPatch.js +311 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +23 -0
  17. package/dist/outbox.d.ts +37 -0
  18. package/dist/outbox.js +80 -0
  19. package/dist/overlay.d.ts +68 -0
  20. package/dist/overlay.js +1946 -0
  21. package/dist/parent-inherit.d.ts +25 -0
  22. package/dist/parent-inherit.js +43 -0
  23. package/dist/recording.d.ts +27 -0
  24. package/dist/recording.js +86 -0
  25. package/dist/rrweb-types.d.ts +13 -0
  26. package/dist/rrweb-types.js +20 -0
  27. package/dist/selectors.d.ts +14 -0
  28. package/dist/selectors.js +91 -0
  29. package/dist/snapshot.d.ts +12 -0
  30. package/dist/snapshot.js +111 -0
  31. package/dist/visitor.d.ts +28 -0
  32. package/dist/visitor.js +107 -0
  33. package/dist/xhrPatch.d.ts +26 -0
  34. package/dist/xhrPatch.js +269 -0
  35. package/package.json +50 -0
  36. package/src/buffer.test.ts +26 -0
  37. package/src/buffer.ts +29 -0
  38. package/src/capture.ts +126 -0
  39. package/src/client.test.ts +89 -0
  40. package/src/client.ts +423 -0
  41. package/src/commands.test.ts +128 -0
  42. package/src/commands.ts +335 -0
  43. package/src/dashboardUrl.test.ts +59 -0
  44. package/src/dashboardUrl.ts +36 -0
  45. package/src/fetchPatch.test.ts +203 -0
  46. package/src/fetchPatch.ts +371 -0
  47. package/src/index.ts +32 -0
  48. package/src/outbox.test.ts +115 -0
  49. package/src/outbox.ts +84 -0
  50. package/src/overlay.test.ts +319 -0
  51. package/src/overlay.ts +2070 -0
  52. package/src/parent-inherit.ts +54 -0
  53. package/src/recording.ts +88 -0
  54. package/src/rrweb-types.test.ts +40 -0
  55. package/src/rrweb-types.ts +24 -0
  56. package/src/selectors.test.ts +50 -0
  57. package/src/selectors.ts +103 -0
  58. package/src/snapshot.ts +112 -0
  59. package/src/visitor.ts +116 -0
  60. package/src/xhrPatch.test.ts +191 -0
  61. package/src/xhrPatch.ts +314 -0
@@ -0,0 +1,1946 @@
1
+ /**
2
+ * In-page overlay — single floating "H" mark in the bottom-right corner.
3
+ *
4
+ * Click → expands into an info card that surfaces:
5
+ * - project / buildId / connection status
6
+ * - sessionId / tabId (click-to-copy)
7
+ * - current URL
8
+ * - "Copy snapshot" — key fields as markdown for sharing with a teammate
9
+ * or pasting into an agent prompt
10
+ * - "Report a problem" — enters element-picker mode (the legacy annotation
11
+ * flow, now reachable from inside the card so users don't see two FABs)
12
+ *
13
+ * Single Shadow DOM root attached to <body>; host page styles never leak in
14
+ * or out. State machine: idle → info → (picker → question) → flash → idle.
15
+ */
16
+ import { EVENT_NAME } from '@harness-fe/protocol';
17
+ import { snapdom } from '@zumer/snapdom';
18
+ import { deriveDashboardUrl } from './dashboardUrl.js';
19
+ const HOST_ID = '__harness_fe_overlay__';
20
+ const MAX_OUTER_HTML = 2048;
21
+ // Internal instrumentation attributes injected by our build plugin. Must be
22
+ // stripped before sending outerHTML to an agent — otherwise the agent treats
23
+ // them as business code and writes them back into the JSX source.
24
+ const INTERNAL_ATTR_RE = /\s+data-morphix-[\w-]*(="[^"]*")?/g;
25
+ function stripInternalAttrs(html) {
26
+ return html.replace(INTERNAL_ATTR_RE, '');
27
+ }
28
+ /**
29
+ * Mark an element so common session-recording / RUM vendors skip it. Our
30
+ * overlay must not pollute the host app's analytics or replay data, and must
31
+ * not leak our session/visitor ids to third-party servers.
32
+ */
33
+ function hideFromThirdParties(el) {
34
+ // rrweb / PostHog
35
+ el.setAttribute('data-rr-block', '');
36
+ el.setAttribute('data-rr-ignore', '');
37
+ el.setAttribute('data-rr-mask', '');
38
+ el.classList.add('ph-no-capture');
39
+ // Sentry session replay
40
+ el.setAttribute('data-sentry-block', '');
41
+ el.setAttribute('data-sentry-ignore', '');
42
+ el.setAttribute('data-sentry-mask', '');
43
+ // Datadog RUM
44
+ el.setAttribute('data-dd-privacy', 'hidden');
45
+ // FullStory
46
+ el.setAttribute('data-fs-exclude', '');
47
+ // LogRocket
48
+ el.setAttribute('data-lr-exclude', '');
49
+ // Hotjar
50
+ el.setAttribute('data-hj-suppress', '');
51
+ // Smartlook
52
+ el.setAttribute('data-recording-disable', '');
53
+ // Microsoft Clarity
54
+ el.setAttribute('data-clarity-mask', 'true');
55
+ // Heap
56
+ el.setAttribute('data-heap-redact-text', '');
57
+ }
58
+ export function installOverlay(client) {
59
+ if (typeof window === 'undefined' || typeof document === 'undefined')
60
+ return;
61
+ if (document.getElementById(HOST_ID))
62
+ return;
63
+ const host = document.createElement('div');
64
+ host.id = HOST_ID;
65
+ host.style.cssText = 'all: initial;';
66
+ hideFromThirdParties(host);
67
+ const root = host.attachShadow({ mode: 'open' });
68
+ root.appendChild(buildStyle());
69
+ const fab = buildFab();
70
+ const infoCard = buildInfoCard();
71
+ const reportsCard = buildReportsCard();
72
+ const pickerBar = buildPickerBar();
73
+ const annotateModal = buildAnnotateModal();
74
+ const questionPanel = buildQuestionPanel();
75
+ const highlight = buildHighlight();
76
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
77
+ const mount = () => {
78
+ if (!document.body)
79
+ return false;
80
+ document.body.appendChild(host);
81
+ return true;
82
+ };
83
+ if (!mount()) {
84
+ document.addEventListener('DOMContentLoaded', () => mount(), { once: true });
85
+ }
86
+ // ─── FAB position (drag-and-drop + persistence) ──────────────────────
87
+ //
88
+ // The FAB lives in a fixed position the user can drag anywhere on
89
+ // screen, with the location stashed in localStorage so it survives
90
+ // reloads. Follower cards (info / reports / question) anchor relative
91
+ // to the FAB and flip side based on available space.
92
+ const FAB_SIZE = 40;
93
+ const FAB_MARGIN = 16;
94
+ const FAB_POS_KEY = '__harness_fe_fab_pos__';
95
+ const DRAG_THRESHOLD = 5; // px before we treat a pointerdown as a drag
96
+ const readPersistedPos = () => {
97
+ try {
98
+ const raw = window.localStorage?.getItem(FAB_POS_KEY);
99
+ if (!raw)
100
+ return undefined;
101
+ const parsed = JSON.parse(raw);
102
+ if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
103
+ return { x: parsed.x, y: parsed.y };
104
+ }
105
+ }
106
+ catch {
107
+ /* swallow — storage unavailable, missing, or malformed */
108
+ }
109
+ return undefined;
110
+ };
111
+ const persistPos = (pos) => {
112
+ try {
113
+ window.localStorage?.setItem(FAB_POS_KEY, JSON.stringify(pos));
114
+ }
115
+ catch {
116
+ /* swallow — quota / sandboxed iframe / etc. */
117
+ }
118
+ };
119
+ const clampPos = (pos) => {
120
+ const w = window.innerWidth || 1024;
121
+ const h = window.innerHeight || 768;
122
+ return {
123
+ x: Math.max(FAB_MARGIN, Math.min(w - FAB_SIZE - FAB_MARGIN, pos.x)),
124
+ y: Math.max(FAB_MARGIN, Math.min(h - FAB_SIZE - FAB_MARGIN, pos.y)),
125
+ };
126
+ };
127
+ const defaultPos = () => {
128
+ const w = window.innerWidth || 1024;
129
+ const h = window.innerHeight || 768;
130
+ return { x: w - FAB_SIZE - FAB_MARGIN, y: h - FAB_SIZE - FAB_MARGIN };
131
+ };
132
+ let fabPos = clampPos(readPersistedPos() ?? defaultPos());
133
+ const applyFabPosition = () => {
134
+ fab.style.left = `${fabPos.x}px`;
135
+ fab.style.top = `${fabPos.y}px`;
136
+ fab.style.right = 'auto';
137
+ fab.style.bottom = 'auto';
138
+ };
139
+ /**
140
+ * Position the visible follower card anchored to the current FAB
141
+ * location. Cards prefer to sit above the FAB; flip below when there
142
+ * isn't enough room above. Horizontal alignment mirrors which half of
143
+ * the viewport the FAB lives in (so a FAB in the left half gets cards
144
+ * extending right, and vice-versa).
145
+ */
146
+ const repositionCards = () => {
147
+ const rect = fab.getBoundingClientRect();
148
+ const halfW = (window.innerWidth || 1024) / 2;
149
+ const winH = window.innerHeight || 768;
150
+ const gap = 12;
151
+ const cards = [
152
+ { el: infoCard, estimatedHeight: 280 },
153
+ { el: reportsCard, estimatedHeight: 460 },
154
+ { el: questionPanel, estimatedHeight: 320 },
155
+ ];
156
+ for (const { el, estimatedHeight } of cards) {
157
+ if (el.style.display === 'none')
158
+ continue;
159
+ const cardW = el.offsetWidth || 320;
160
+ const cardH = el.offsetHeight || estimatedHeight;
161
+ const onLeftHalf = rect.left + rect.width / 2 < halfW;
162
+ // Horizontal: align the card's left or right edge to the FAB's
163
+ let left = onLeftHalf ? rect.left : rect.right - cardW;
164
+ left = Math.max(8, Math.min((window.innerWidth || 1024) - cardW - 8, left));
165
+ // Vertical: prefer above the FAB; flip below when constrained.
166
+ let top;
167
+ if (rect.top - gap - cardH >= 8) {
168
+ top = rect.top - gap - cardH;
169
+ }
170
+ else if (rect.bottom + gap + cardH <= winH - 8) {
171
+ top = rect.bottom + gap;
172
+ }
173
+ else {
174
+ // Both directions constrained: center vertically.
175
+ top = Math.max(8, Math.min(winH - cardH - 8, rect.top - cardH / 2));
176
+ }
177
+ el.style.left = `${left}px`;
178
+ el.style.top = `${top}px`;
179
+ el.style.right = 'auto';
180
+ el.style.bottom = 'auto';
181
+ }
182
+ };
183
+ applyFabPosition();
184
+ // ─── Drag handlers ───────────────────────────────────────────────────
185
+ let dragOriginPos = null;
186
+ let pointerDownAt = null;
187
+ let dragging = false;
188
+ let suppressNextClick = false;
189
+ const onFabPointerDown = (ev) => {
190
+ if (ev.button !== 0)
191
+ return;
192
+ try {
193
+ fab.setPointerCapture(ev.pointerId);
194
+ }
195
+ catch { /* swallow */ }
196
+ dragOriginPos = { x: fabPos.x, y: fabPos.y };
197
+ pointerDownAt = { x: ev.clientX, y: ev.clientY };
198
+ dragging = false;
199
+ };
200
+ const onFabPointerMove = (ev) => {
201
+ if (!pointerDownAt || !dragOriginPos)
202
+ return;
203
+ const dx = ev.clientX - pointerDownAt.x;
204
+ const dy = ev.clientY - pointerDownAt.y;
205
+ if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD)
206
+ return;
207
+ dragging = true;
208
+ suppressNextClick = true;
209
+ fab.dataset.dragging = '1';
210
+ fabPos = clampPos({ x: dragOriginPos.x + dx, y: dragOriginPos.y + dy });
211
+ applyFabPosition();
212
+ repositionCards();
213
+ };
214
+ const onFabPointerUp = (ev) => {
215
+ try {
216
+ fab.releasePointerCapture(ev.pointerId);
217
+ }
218
+ catch { /* swallow */ }
219
+ const wasDragging = dragging;
220
+ dragOriginPos = null;
221
+ pointerDownAt = null;
222
+ dragging = false;
223
+ delete fab.dataset.dragging;
224
+ if (wasDragging)
225
+ persistPos(fabPos);
226
+ };
227
+ fab.addEventListener('pointerdown', onFabPointerDown);
228
+ fab.addEventListener('pointermove', onFabPointerMove);
229
+ fab.addEventListener('pointerup', onFabPointerUp);
230
+ fab.addEventListener('pointercancel', onFabPointerUp);
231
+ // Keep things on-screen when the viewport changes (window resize,
232
+ // dev tools open, mobile keyboard, …).
233
+ const onWindowResize = () => {
234
+ fabPos = clampPos(fabPos);
235
+ applyFabPosition();
236
+ repositionCards();
237
+ };
238
+ window.addEventListener('resize', onWindowResize);
239
+ let state = 'idle';
240
+ let hoveredEl = null;
241
+ let lockedEl = null;
242
+ let statusPollTimer;
243
+ /** Flattened PNG from the annotate step; null if user skipped. */
244
+ let pendingAttachment = null;
245
+ const setState = (next) => {
246
+ state = next;
247
+ infoCard.style.display = next === 'info' ? 'flex' : 'none';
248
+ reportsCard.style.display = next === 'reports' ? 'flex' : 'none';
249
+ pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
250
+ annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
251
+ questionPanel.style.display = next === 'question' ? 'flex' : 'none';
252
+ document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
253
+ fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
254
+ if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
255
+ highlight.style.display = 'none';
256
+ }
257
+ if (next === 'info') {
258
+ renderInfo();
259
+ if (!statusPollTimer) {
260
+ statusPollTimer = window.setInterval(renderConnectionDot, 1000);
261
+ }
262
+ }
263
+ else if (statusPollTimer) {
264
+ window.clearInterval(statusPollTimer);
265
+ statusPollTimer = undefined;
266
+ }
267
+ if (next === 'reports') {
268
+ void refreshReports();
269
+ }
270
+ if (next === 'annotate' && lockedEl) {
271
+ void enterAnnotate(lockedEl);
272
+ }
273
+ // Anchor the just-revealed follower card relative to the FAB. Wait
274
+ // one frame so `display: flex` has applied and offsetWidth/Height
275
+ // are real (otherwise the first render uses fallback estimates).
276
+ if (next === 'info' || next === 'reports' || next === 'question') {
277
+ requestAnimationFrame(() => repositionCards());
278
+ }
279
+ };
280
+ // ─── Picker handlers ─────────────────────────────────────────────────
281
+ const setHighlight = (el) => {
282
+ if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
283
+ highlight.style.display = 'none';
284
+ return;
285
+ }
286
+ const rect = el.getBoundingClientRect();
287
+ highlight.style.display = 'block';
288
+ highlight.style.left = `${rect.left + window.scrollX}px`;
289
+ highlight.style.top = `${rect.top + window.scrollY}px`;
290
+ highlight.style.width = `${rect.width}px`;
291
+ highlight.style.height = `${rect.height}px`;
292
+ };
293
+ const onMouseMove = (ev) => {
294
+ if (state !== 'picker')
295
+ return;
296
+ const target = document.elementFromPoint(ev.clientX, ev.clientY);
297
+ if (!target || target === host || host.contains(target)) {
298
+ hoveredEl = null;
299
+ highlight.style.display = 'none';
300
+ return;
301
+ }
302
+ hoveredEl = target;
303
+ setHighlight(target);
304
+ };
305
+ const onClickCapture = (ev) => {
306
+ if (state !== 'picker')
307
+ return;
308
+ if (ev.target === host || host.contains(ev.target))
309
+ return;
310
+ ev.preventDefault();
311
+ ev.stopPropagation();
312
+ ev.stopImmediatePropagation();
313
+ if (!hoveredEl)
314
+ return;
315
+ lockedEl = hoveredEl;
316
+ setHighlight(lockedEl);
317
+ // Go straight to the question step. Screenshots are now opt-in via
318
+ // the "Add screenshot" button inside the question panel — users
319
+ // shouldn't have to draw on every report.
320
+ pendingAttachment = null;
321
+ const info = questionPanel.querySelector('[data-role=info]');
322
+ info.textContent = describeElement(lockedEl);
323
+ const textarea = questionPanel.querySelector('textarea');
324
+ textarea.value = '';
325
+ renderAttachmentPreview();
326
+ setState('question');
327
+ setTimeout(() => textarea.focus(), 0);
328
+ };
329
+ const onKeyDown = (ev) => {
330
+ if (ev.key === 'Escape') {
331
+ if (state === 'annotate') {
332
+ // Esc in annotate: keep any prior attachment, just discard the
333
+ // in-progress strokes and return to the question step where
334
+ // the user came from. Re-entering annotate later will start
335
+ // a fresh canvas.
336
+ resetAnnotateStrokes();
337
+ setState('question');
338
+ }
339
+ else if (state === 'picker' || state === 'question') {
340
+ lockedEl = null;
341
+ pendingAttachment = null;
342
+ setState('info');
343
+ }
344
+ else if (state === 'info') {
345
+ setState('idle');
346
+ }
347
+ return;
348
+ }
349
+ // Cmd/Ctrl + Shift + H toggles the info card.
350
+ const meta = ev.metaKey || ev.ctrlKey;
351
+ if (meta && ev.shiftKey && (ev.key === 'h' || ev.key === 'H')) {
352
+ ev.preventDefault();
353
+ setState(state === 'idle' ? 'info' : 'idle');
354
+ }
355
+ };
356
+ // ─── Info card rendering ─────────────────────────────────────────────
357
+ const renderInfo = () => {
358
+ const proj = infoCard.querySelector('[data-role=project]');
359
+ const build = infoCard.querySelector('[data-role=build]');
360
+ const session = infoCard.querySelector('[data-role=session]');
361
+ const tab = infoCard.querySelector('[data-role=tab]');
362
+ const url = infoCard.querySelector('[data-role=url]');
363
+ proj.textContent = client.displayName ?? client.projectId;
364
+ build.textContent = client.buildId ? abbr(client.buildId) : '—';
365
+ build.title = client.buildId ?? 'No buildId — set HarnessScript buildId prop in prod';
366
+ session.textContent = abbr(client.sessionId);
367
+ session.title = client.sessionId;
368
+ tab.textContent = abbr(client.tabId);
369
+ tab.title = client.tabId;
370
+ url.textContent = shortenUrl(location.href);
371
+ url.title = location.href;
372
+ renderConnectionDot();
373
+ };
374
+ const renderConnectionDot = () => {
375
+ const dot = infoCard.querySelector('[data-role=dot]');
376
+ const label = infoCard.querySelector('[data-role=conn]');
377
+ const state = client.getConnectionState();
378
+ dot.dataset.state = state;
379
+ label.textContent =
380
+ state === 'open' ? 'connected' :
381
+ state === 'connecting' ? 'connecting' : 'disconnected';
382
+ };
383
+ // ─── Copy buttons ────────────────────────────────────────────────────
384
+ const copyText = async (text, feedback) => {
385
+ try {
386
+ await navigator.clipboard.writeText(text);
387
+ }
388
+ catch {
389
+ // Fallback for non-secure contexts.
390
+ const ta = document.createElement('textarea');
391
+ ta.value = text;
392
+ ta.style.position = 'fixed';
393
+ ta.style.opacity = '0';
394
+ document.body.appendChild(ta);
395
+ ta.select();
396
+ try {
397
+ document.execCommand('copy');
398
+ }
399
+ catch { /* swallow */ }
400
+ ta.remove();
401
+ }
402
+ if (feedback) {
403
+ const orig = feedback.textContent;
404
+ feedback.dataset.copied = '1';
405
+ feedback.textContent = '✓ copied';
406
+ setTimeout(() => {
407
+ delete feedback.dataset.copied;
408
+ feedback.textContent = orig ?? '';
409
+ }, 1200);
410
+ }
411
+ };
412
+ const buildSnapshot = () => {
413
+ const lines = [];
414
+ lines.push(`### Harness-FE snapshot`);
415
+ lines.push('');
416
+ lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
417
+ if (client.buildId)
418
+ lines.push(`- build: \`${client.buildId}\``);
419
+ lines.push(`- session: \`${client.sessionId}\``);
420
+ lines.push(`- tab: \`${client.tabId}\``);
421
+ if (client.parentProjectId)
422
+ lines.push(`- parent project: \`${client.parentProjectId}\``);
423
+ lines.push(`- url: ${location.href}`);
424
+ lines.push(`- time: ${new Date().toISOString()}`);
425
+ lines.push(`- daemon: ${client.getConnectionState()}`);
426
+ return lines.join('\n') + '\n';
427
+ };
428
+ // ─── Reports rendering ───────────────────────────────────────────────
429
+ let editingTaskId = null;
430
+ let deleteConfirmId = null;
431
+ let deleteConfirmTimer;
432
+ const refreshReports = async () => {
433
+ const list = reportsCard.querySelector('[data-role=list]');
434
+ const countEl = reportsCard.querySelector('[data-role=count]');
435
+ list.innerHTML = '<div class="loading">Loading…</div>';
436
+ countEl.textContent = '';
437
+ if (!client.query) {
438
+ list.innerHTML = `<div class="empty">RPC channel not available — daemon may be too old.</div>`;
439
+ return;
440
+ }
441
+ try {
442
+ const res = await client.query('tasks.mine', { limit: 50 });
443
+ const tasks = Array.isArray(res?.tasks) ? res.tasks : [];
444
+ countEl.textContent = tasks.length === 0 ? '' : `(${tasks.length})`;
445
+ renderTasks(list, tasks);
446
+ }
447
+ catch (err) {
448
+ const message = err instanceof Error ? err.message : String(err);
449
+ list.innerHTML = `<div class="empty error">Failed to load: ${escapeHtml(message)}</div>`;
450
+ }
451
+ };
452
+ const renderTasks = (list, tasks) => {
453
+ if (tasks.length === 0) {
454
+ list.innerHTML = `<div class="empty">No reports yet — click "Report a problem" to file one.</div>`;
455
+ return;
456
+ }
457
+ const html = tasks.map((t) => renderTaskRow(t)).join('');
458
+ list.innerHTML = html;
459
+ // Delegate clicks
460
+ list.querySelectorAll('[data-action]').forEach((btn) => {
461
+ btn.addEventListener('click', (ev) => {
462
+ ev.stopPropagation();
463
+ const action = btn.dataset.action;
464
+ const id = btn.dataset.id;
465
+ const task = tasks.find((x) => x.id === id);
466
+ if (!task)
467
+ return;
468
+ handleTaskAction(action, task);
469
+ });
470
+ });
471
+ };
472
+ const renderTaskRow = (t) => {
473
+ const statusIcon = t.status === 'pending' ? '⏳' : t.status === 'claimed' ? '⊕' : '✓';
474
+ const ago = formatAgo(t.createdAt);
475
+ const fileBit = t.selector.loc ?? t.selector.comp ?? '';
476
+ const url = shortenUrl(t.url);
477
+ const isEditing = editingTaskId === t.id;
478
+ const isConfirmingDelete = deleteConfirmId === t.id;
479
+ const editBody = isEditing
480
+ ? `<textarea class="edit-area" data-action="edit-input" data-id="${escapeAttr(t.id)}">${escapeHtml(t.question)}</textarea>
481
+ <div class="row-actions">
482
+ <button class="rl-btn" data-action="edit-save" data-id="${escapeAttr(t.id)}">Save</button>
483
+ <button class="rl-btn ghost" data-action="edit-cancel" data-id="${escapeAttr(t.id)}">Cancel</button>
484
+ </div>`
485
+ : `<div class="q">${escapeHtml(t.question)}</div>
486
+ ${t.note ? `<div class="note">↳ ${escapeHtml(t.note)}</div>` : ''}
487
+ ${t.attachments?.[0]?.data ? `<img class="task-thumb" data-action="thumb" data-id="${escapeAttr(t.id)}" src="data:image/png;base64,${escapeAttr(t.attachments[0].data)}" alt="screenshot" title="Click to enlarge" />` : ''}
488
+ <div class="meta">${escapeHtml(fileBit)}${fileBit && url ? ' · ' : ''}${escapeHtml(url)}</div>
489
+ <div class="row-actions">
490
+ ${t.status !== 'resolved' ? `<button class="rl-btn" data-action="edit" data-id="${escapeAttr(t.id)}">edit</button>` : ''}
491
+ <button class="rl-btn" data-action="copy" data-id="${escapeAttr(t.id)}">copy</button>
492
+ ${isConfirmingDelete
493
+ ? `<button class="rl-btn danger" data-action="confirm-delete" data-id="${escapeAttr(t.id)}">confirm ×</button>`
494
+ : `<button class="rl-btn ghost" data-action="delete" data-id="${escapeAttr(t.id)}">×</button>`}
495
+ </div>`;
496
+ return `
497
+ <div class="task-row" data-status="${escapeAttr(t.status)}">
498
+ <div class="status">${statusIcon} <span class="ago">${escapeHtml(ago)}</span></div>
499
+ ${editBody}
500
+ </div>
501
+ `;
502
+ };
503
+ const handleTaskAction = async (action, task) => {
504
+ const list = reportsCard.querySelector('[data-role=list]');
505
+ switch (action) {
506
+ case 'edit':
507
+ editingTaskId = task.id;
508
+ deleteConfirmId = null;
509
+ await refreshReports();
510
+ return;
511
+ case 'edit-cancel':
512
+ editingTaskId = null;
513
+ await refreshReports();
514
+ return;
515
+ case 'edit-save': {
516
+ const ta = list.querySelector(`textarea[data-id="${task.id}"]`);
517
+ const newQuestion = ta?.value.trim() ?? '';
518
+ if (!newQuestion) {
519
+ ta?.focus();
520
+ return;
521
+ }
522
+ if (!client.query)
523
+ return;
524
+ try {
525
+ await client.query('tasks.update', { id: task.id, question: newQuestion });
526
+ editingTaskId = null;
527
+ await refreshReports();
528
+ }
529
+ catch (err) {
530
+ console.warn('[harness-fe] tasks.update failed:', err);
531
+ }
532
+ return;
533
+ }
534
+ case 'thumb': {
535
+ const att = task.attachments?.[0];
536
+ if (att?.data) {
537
+ showLightbox(root, `data:image/png;base64,${att.data}`);
538
+ }
539
+ return;
540
+ }
541
+ case 'copy':
542
+ void copyText(buildTaskSnapshot(task));
543
+ return;
544
+ case 'delete':
545
+ deleteConfirmId = task.id;
546
+ editingTaskId = null;
547
+ if (deleteConfirmTimer)
548
+ window.clearTimeout(deleteConfirmTimer);
549
+ deleteConfirmTimer = window.setTimeout(() => {
550
+ deleteConfirmId = null;
551
+ void refreshReports();
552
+ }, 3000);
553
+ await refreshReports();
554
+ return;
555
+ case 'confirm-delete':
556
+ if (!client.query)
557
+ return;
558
+ try {
559
+ await client.query('tasks.delete', { id: task.id });
560
+ deleteConfirmId = null;
561
+ if (deleteConfirmTimer)
562
+ window.clearTimeout(deleteConfirmTimer);
563
+ await refreshReports();
564
+ }
565
+ catch (err) {
566
+ console.warn('[harness-fe] tasks.delete failed:', err);
567
+ }
568
+ return;
569
+ }
570
+ };
571
+ const buildTaskSnapshot = (t) => {
572
+ const lines = [];
573
+ lines.push(`### Harness-FE task ${t.id} (${t.status})`);
574
+ lines.push('');
575
+ lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
576
+ if (client.buildId)
577
+ lines.push(`- build: \`${client.buildId}\``);
578
+ if (client.visitorId)
579
+ lines.push(`- visitor: \`${client.visitorId}\``);
580
+ if (t.selector.loc)
581
+ lines.push(`- source: \`${t.selector.loc}\``);
582
+ if (t.selector.comp)
583
+ lines.push(`- component: \`${t.selector.comp}\``);
584
+ lines.push(`- url: ${t.url}`);
585
+ lines.push(`- submitted: ${new Date(t.createdAt).toISOString()}`);
586
+ if (t.attachments && t.attachments.length > 0) {
587
+ for (const att of t.attachments) {
588
+ const dims = att.width && att.height ? ` (${att.width}×${att.height})` : '';
589
+ const pathStr = att.path ?? `task-attachments/${t.id}/${att.id}.png`;
590
+ lines.push(`- attachment: ${pathStr}${dims}`);
591
+ }
592
+ }
593
+ lines.push('');
594
+ lines.push(`**question:** ${t.question}`);
595
+ if (t.note) {
596
+ lines.push('');
597
+ lines.push(`**agent note:** ${t.note}`);
598
+ }
599
+ lines.push('');
600
+ lines.push('Agent commands:');
601
+ lines.push('```');
602
+ lines.push(`mcp call tasks.claim { "taskId": "${t.id}" }`);
603
+ if (t.attachments?.[0]) {
604
+ lines.push(`mcp call tasks.get_attachment { "taskId": "${t.id}", "attachmentId": "${t.attachments[0].id}" }`);
605
+ }
606
+ if (t.selector.loc)
607
+ lines.push(`mcp call project.where_is { "loc": "${t.selector.loc}" }`);
608
+ lines.push('```');
609
+ return lines.join('\n') + '\n';
610
+ };
611
+ /** Show a full-screen lightbox in the shadow DOM. Click anywhere to close. */
612
+ const showLightbox = (shadowRoot, src) => {
613
+ const existing = shadowRoot.querySelector('.thumb-lightbox');
614
+ if (existing) {
615
+ existing.remove();
616
+ return;
617
+ }
618
+ const lb = document.createElement('div');
619
+ lb.className = 'thumb-lightbox';
620
+ const img = document.createElement('img');
621
+ img.src = src;
622
+ img.alt = 'Screenshot annotation';
623
+ lb.appendChild(img);
624
+ lb.addEventListener('click', () => lb.remove());
625
+ shadowRoot.appendChild(lb);
626
+ };
627
+ // ─── Wire interactions ───────────────────────────────────────────────
628
+ fab.addEventListener('click', (ev) => {
629
+ // Suppress the synthetic click that follows a drag — the user
630
+ // dragged the FAB, they didn't intend to open the info card.
631
+ if (suppressNextClick) {
632
+ suppressNextClick = false;
633
+ ev.preventDefault();
634
+ ev.stopPropagation();
635
+ return;
636
+ }
637
+ setState(state === 'idle' ? 'info' : 'idle');
638
+ });
639
+ infoCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
640
+ infoCard.querySelector('[data-role=report]').addEventListener('click', () => {
641
+ setState('picker');
642
+ });
643
+ infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
644
+ const btn = ev.currentTarget;
645
+ void copyText(buildSnapshot(), btn);
646
+ });
647
+ infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
648
+ setState('reports');
649
+ });
650
+ // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
651
+ // pop it in a new tab, deep-linked to this session. Show the button
652
+ // only when we actually know the daemon address (mcpUrl was supplied by
653
+ // the plugin / runtime config).
654
+ {
655
+ const dashboardBtn = infoCard.querySelector('[data-role=open-dashboard]');
656
+ const dashboardUrl = client.mcpUrl
657
+ ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
658
+ : undefined;
659
+ if (dashboardUrl) {
660
+ dashboardBtn.style.display = '';
661
+ dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
662
+ dashboardBtn.addEventListener('click', () => {
663
+ try {
664
+ window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
665
+ }
666
+ catch {
667
+ // Popup blocked or sandboxed iframe — copy as fallback so
668
+ // the user can paste it into the address bar manually.
669
+ void copyText(dashboardUrl, dashboardBtn);
670
+ }
671
+ });
672
+ }
673
+ }
674
+ reportsCard.querySelector('[data-role=back]').addEventListener('click', () => setState('info'));
675
+ reportsCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
676
+ reportsCard.querySelector('[data-role=refresh]').addEventListener('click', () => void refreshReports());
677
+ // Click on session / tab pill to copy that single value.
678
+ for (const role of ['session', 'tab', 'build']) {
679
+ const pill = infoCard.querySelector(`[data-role=${role}]`);
680
+ pill.addEventListener('click', () => {
681
+ const value = role === 'session' ? client.sessionId :
682
+ role === 'tab' ? client.tabId :
683
+ client.buildId ?? '';
684
+ if (!value)
685
+ return;
686
+ void copyText(value, pill);
687
+ });
688
+ }
689
+ pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
690
+ lockedEl = null;
691
+ setState('info');
692
+ });
693
+ questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
694
+ lockedEl = null;
695
+ setState('info');
696
+ });
697
+ questionPanel.querySelector('[data-role=submit]').addEventListener('click', () => {
698
+ if (!lockedEl)
699
+ return;
700
+ const textarea = questionPanel.querySelector('textarea');
701
+ const question = textarea.value.trim();
702
+ if (!question) {
703
+ textarea.focus();
704
+ return;
705
+ }
706
+ const payload = buildPayload(lockedEl, question, pendingAttachment ?? undefined);
707
+ client.sendEvent(EVENT_NAME.TASK_SUBMIT, payload);
708
+ flashFab(fab);
709
+ lockedEl = null;
710
+ pendingAttachment = null;
711
+ setState('idle');
712
+ });
713
+ // ─── Question panel ↔ Annotate modal wiring ──────────────────────────
714
+ // Re-render the attachment preview block based on `pendingAttachment`.
715
+ const renderAttachmentPreview = () => {
716
+ const area = questionPanel.querySelector('[data-role=attach-area]');
717
+ const addBtn = area.querySelector('[data-role=add-shot]');
718
+ const preview = area.querySelector('[data-role=thumb-preview]');
719
+ const img = preview.querySelector('[data-role=thumb-img]');
720
+ const dims = preview.querySelector('[data-role=thumb-dims]');
721
+ if (pendingAttachment && pendingAttachment.data) {
722
+ addBtn.style.display = 'none';
723
+ preview.style.display = 'flex';
724
+ img.src = `data:image/png;base64,${pendingAttachment.data}`;
725
+ dims.textContent = `${pendingAttachment.width}×${pendingAttachment.height}`;
726
+ }
727
+ else {
728
+ addBtn.style.display = 'inline-flex';
729
+ preview.style.display = 'none';
730
+ img.src = '';
731
+ }
732
+ };
733
+ questionPanel.querySelector('[data-role=add-shot]').addEventListener('click', () => {
734
+ // Launch the annotate modal. lockedEl is still set from picker.
735
+ if (!lockedEl)
736
+ return;
737
+ setState('annotate');
738
+ });
739
+ questionPanel.querySelector('[data-role=thumb-edit]').addEventListener('click', () => {
740
+ // Discard current PNG; re-enter annotate to recapture.
741
+ pendingAttachment = null;
742
+ resetAnnotateStrokes();
743
+ renderAttachmentPreview();
744
+ if (!lockedEl)
745
+ return;
746
+ setState('annotate');
747
+ });
748
+ questionPanel.querySelector('[data-role=thumb-remove]').addEventListener('click', () => {
749
+ pendingAttachment = null;
750
+ renderAttachmentPreview();
751
+ });
752
+ annotateModal.querySelector('[data-role=annotate-cancel]').addEventListener('click', () => {
753
+ // Cancel the in-progress annotation; keep whatever pendingAttachment
754
+ // existed before this annotate cycle (typically null).
755
+ resetAnnotateStrokes();
756
+ setState('question');
757
+ });
758
+ annotateModal.querySelector('[data-role=annotate-done]').addEventListener('click', () => {
759
+ void finalizeAnnotation().then((attachment) => {
760
+ if (attachment)
761
+ pendingAttachment = attachment;
762
+ renderAttachmentPreview();
763
+ setState('question');
764
+ const textarea = questionPanel.querySelector('textarea');
765
+ // Don't clobber user's typed question. Focus the textarea so they
766
+ // can keep writing.
767
+ setTimeout(() => textarea.focus(), 0);
768
+ });
769
+ });
770
+ document.addEventListener('mousemove', onMouseMove, true);
771
+ document.addEventListener('click', onClickCapture, true);
772
+ document.addEventListener('keydown', onKeyDown, true);
773
+ }
774
+ const ANNOTATE_COLORS = {
775
+ red: '#ef4444',
776
+ blue: '#3b82f6',
777
+ yellow: '#eab308',
778
+ green: '#22c55e',
779
+ black: '#111827',
780
+ };
781
+ let _annotateCanvas = null;
782
+ let _annotateCtx = null;
783
+ let _annotateBackground = null;
784
+ let _annotateStrokes = [];
785
+ let _annotateTool = 'arrow';
786
+ let _annotateColor = 'red';
787
+ function resetAnnotateStrokes() {
788
+ _annotateStrokes = [];
789
+ _annotateTool = 'arrow';
790
+ _annotateColor = 'red';
791
+ _annotateCanvas = null;
792
+ _annotateCtx = null;
793
+ _annotateBackground = null;
794
+ }
795
+ function drawArrow(ctx, x1, y1, x2, y2, color) {
796
+ const headLen = 14;
797
+ const angle = Math.atan2(y2 - y1, x2 - x1);
798
+ ctx.save();
799
+ ctx.strokeStyle = color;
800
+ ctx.fillStyle = color;
801
+ ctx.lineWidth = 2.5;
802
+ ctx.lineCap = 'round';
803
+ ctx.beginPath();
804
+ ctx.moveTo(x1, y1);
805
+ ctx.lineTo(x2, y2);
806
+ ctx.stroke();
807
+ // Arrowhead triangle at endpoint
808
+ ctx.beginPath();
809
+ ctx.moveTo(x2, y2);
810
+ ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
811
+ ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
812
+ ctx.closePath();
813
+ ctx.fill();
814
+ ctx.restore();
815
+ }
816
+ function drawTextLabel(ctx, x, y, text, color) {
817
+ ctx.save();
818
+ ctx.font = 'bold 14px system-ui, -apple-system, sans-serif';
819
+ const metrics = ctx.measureText(text);
820
+ const pad = 4;
821
+ const bw = metrics.width + pad * 2;
822
+ const bh = 20;
823
+ // White background for readability
824
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
825
+ ctx.fillRect(x - pad, y - bh + pad, bw, bh);
826
+ ctx.fillStyle = color;
827
+ ctx.fillText(text, x, y);
828
+ ctx.restore();
829
+ }
830
+ export function replayStrokes(ctx, bgCanvas, strokes) {
831
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
832
+ ctx.drawImage(bgCanvas, 0, 0);
833
+ for (const s of strokes) {
834
+ if (s.kind === 'arrow') {
835
+ drawArrow(ctx, s.x1, s.y1, s.x2, s.y2, s.color);
836
+ }
837
+ else {
838
+ drawTextLabel(ctx, s.x, s.y, s.text, s.color);
839
+ }
840
+ }
841
+ }
842
+ async function enterAnnotate(el) {
843
+ // Find the annotate canvas in the shadow DOM
844
+ const host = document.getElementById(HOST_ID);
845
+ if (!host)
846
+ return;
847
+ const modal = host.shadowRoot.querySelector('.annotate-modal');
848
+ if (!modal)
849
+ return;
850
+ let bgCanvas;
851
+ try {
852
+ // Capture the element with a fast pass; snapdom doesn't have a padding option
853
+ // so we capture as-is. The captured area is the element's bounding box.
854
+ const result = await snapdom(el, { fast: true });
855
+ bgCanvas = await result.toCanvas();
856
+ }
857
+ catch {
858
+ // snapdom failed (test env, cross-origin, etc.); create blank canvas
859
+ const rect = el.getBoundingClientRect();
860
+ bgCanvas = document.createElement('canvas');
861
+ bgCanvas.width = Math.max(1, Math.round(rect.width + 64));
862
+ bgCanvas.height = Math.max(1, Math.round(rect.height + 64));
863
+ }
864
+ const canvasSlot = modal.querySelector('.annotate-canvas-wrap');
865
+ if (!canvasSlot)
866
+ return;
867
+ // Replace canvas node
868
+ const canvas = document.createElement('canvas');
869
+ canvas.className = 'annotate-canvas';
870
+ canvas.width = bgCanvas.width;
871
+ canvas.height = bgCanvas.height;
872
+ canvasSlot.innerHTML = '';
873
+ canvasSlot.appendChild(canvas);
874
+ const ctx = canvas.getContext('2d');
875
+ if (!ctx)
876
+ return;
877
+ ctx.drawImage(bgCanvas, 0, 0);
878
+ _annotateCanvas = canvas;
879
+ _annotateCtx = ctx;
880
+ _annotateBackground = bgCanvas;
881
+ _annotateStrokes = [];
882
+ _annotateTool = 'arrow';
883
+ _annotateColor = 'red';
884
+ updateAnnotateToolbar(modal);
885
+ wireAnnotateCanvas(canvas, ctx, bgCanvas, modal);
886
+ }
887
+ function updateAnnotateToolbar(modal) {
888
+ modal.querySelectorAll('[data-role=annotate-tool]').forEach((btn) => {
889
+ btn.dataset.active = btn.dataset.tool === _annotateTool ? '1' : '';
890
+ });
891
+ modal.querySelectorAll('[data-role=annotate-color]').forEach((sw) => {
892
+ sw.dataset.active = sw.dataset.color === _annotateColor ? '1' : '';
893
+ });
894
+ }
895
+ function wireAnnotateCanvas(canvas, ctx, bgCanvas, modal) {
896
+ let dragging = false;
897
+ let startX = 0;
898
+ let startY = 0;
899
+ const getXY = (ev) => {
900
+ const rect = canvas.getBoundingClientRect();
901
+ const scaleX = canvas.width / rect.width;
902
+ const scaleY = canvas.height / rect.height;
903
+ return {
904
+ x: (ev.clientX - rect.left) * scaleX,
905
+ y: (ev.clientY - rect.top) * scaleY,
906
+ };
907
+ };
908
+ canvas.addEventListener('pointerdown', (ev) => {
909
+ ev.preventDefault();
910
+ const { x, y } = getXY(ev);
911
+ if (_annotateTool === 'arrow') {
912
+ dragging = true;
913
+ startX = x;
914
+ startY = y;
915
+ }
916
+ else {
917
+ spawnTextInput(canvas, ctx, bgCanvas, x, y, modal);
918
+ }
919
+ });
920
+ canvas.addEventListener('pointermove', (ev) => {
921
+ if (!dragging || _annotateTool !== 'arrow')
922
+ return;
923
+ ev.preventDefault();
924
+ const { x, y } = getXY(ev);
925
+ replayStrokes(ctx, bgCanvas, _annotateStrokes);
926
+ drawArrow(ctx, startX, startY, x, y, ANNOTATE_COLORS[_annotateColor]);
927
+ });
928
+ canvas.addEventListener('pointerup', (ev) => {
929
+ if (!dragging || _annotateTool !== 'arrow')
930
+ return;
931
+ dragging = false;
932
+ ev.preventDefault();
933
+ const { x, y } = getXY(ev);
934
+ if (Math.abs(x - startX) < 3 && Math.abs(y - startY) < 3)
935
+ return; // too short
936
+ _annotateStrokes.push({
937
+ kind: 'arrow',
938
+ color: ANNOTATE_COLORS[_annotateColor],
939
+ x1: startX, y1: startY,
940
+ x2: x, y2: y,
941
+ });
942
+ replayStrokes(ctx, bgCanvas, _annotateStrokes);
943
+ });
944
+ // Toolbar button wiring
945
+ modal.querySelectorAll('[data-role=annotate-tool]').forEach((btn) => {
946
+ btn.onclick = () => {
947
+ _annotateTool = btn.dataset.tool ?? 'arrow';
948
+ updateAnnotateToolbar(modal);
949
+ };
950
+ });
951
+ modal.querySelectorAll('[data-role=annotate-color]').forEach((sw) => {
952
+ sw.onclick = () => {
953
+ _annotateColor = sw.dataset.color ?? 'red';
954
+ updateAnnotateToolbar(modal);
955
+ };
956
+ });
957
+ const undoBtn = modal.querySelector('[data-role=annotate-undo]');
958
+ if (undoBtn) {
959
+ undoBtn.onclick = () => {
960
+ _annotateStrokes.pop();
961
+ replayStrokes(ctx, bgCanvas, _annotateStrokes);
962
+ };
963
+ }
964
+ }
965
+ function spawnTextInput(canvas, ctx, bgCanvas, x, y, modal) {
966
+ modal.querySelector('.annotate-text-input')?.remove();
967
+ const input = document.createElement('input');
968
+ input.type = 'text';
969
+ input.className = 'annotate-text-input';
970
+ input.maxLength = 140;
971
+ input.placeholder = 'Type label…';
972
+ const rect = canvas.getBoundingClientRect();
973
+ const scaleX = rect.width / canvas.width;
974
+ const scaleY = rect.height / canvas.height;
975
+ input.style.cssText = `
976
+ position: fixed;
977
+ left: ${rect.left + x * scaleX}px;
978
+ top: ${rect.top + y * scaleY - 20}px;
979
+ z-index: 2147483647;
980
+ font: bold 13px system-ui, sans-serif;
981
+ border: 1px solid #3b82f6;
982
+ border-radius: 4px;
983
+ padding: 2px 6px;
984
+ background: #fff;
985
+ color: #111;
986
+ outline: none;
987
+ min-width: 120px;
988
+ `;
989
+ modal.appendChild(input);
990
+ setTimeout(() => input.focus(), 0);
991
+ const commit = () => {
992
+ const text = input.value.trim();
993
+ input.remove();
994
+ if (!text)
995
+ return;
996
+ _annotateStrokes.push({
997
+ kind: 'text',
998
+ color: ANNOTATE_COLORS[_annotateColor],
999
+ x, y,
1000
+ text,
1001
+ });
1002
+ replayStrokes(ctx, bgCanvas, _annotateStrokes);
1003
+ };
1004
+ input.addEventListener('keydown', (ev) => {
1005
+ if (ev.key === 'Enter') {
1006
+ ev.preventDefault();
1007
+ commit();
1008
+ }
1009
+ if (ev.key === 'Escape') {
1010
+ ev.preventDefault();
1011
+ input.remove();
1012
+ }
1013
+ });
1014
+ input.addEventListener('blur', commit);
1015
+ }
1016
+ /**
1017
+ * Flatten strokes onto the background canvas and return as a TaskAttachment.
1018
+ * Exported for testing.
1019
+ */
1020
+ export async function finalizeAnnotation() {
1021
+ if (!_annotateCanvas || !_annotateCtx || !_annotateBackground)
1022
+ return null;
1023
+ const ctx = _annotateCtx;
1024
+ const bgCanvas = _annotateBackground;
1025
+ const canvas = _annotateCanvas;
1026
+ replayStrokes(ctx, bgCanvas, _annotateStrokes);
1027
+ const dataUrl = canvas.toDataURL('image/png', 0.85);
1028
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
1029
+ const id = `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1030
+ const att = {
1031
+ id,
1032
+ kind: 'screenshot',
1033
+ data: base64,
1034
+ width: canvas.width,
1035
+ height: canvas.height,
1036
+ };
1037
+ resetAnnotateStrokes();
1038
+ return att;
1039
+ }
1040
+ // ─── DOM builders ────────────────────────────────────────────────────────
1041
+ function buildStyle() {
1042
+ const style = document.createElement('style');
1043
+ style.textContent = `
1044
+ :host { all: initial; }
1045
+ .fab {
1046
+ position: fixed;
1047
+ width: 40px;
1048
+ height: 40px;
1049
+ border-radius: 12px;
1050
+ background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
1051
+ color: #fff;
1052
+ border: 1px solid rgba(255, 255, 255, 0.08);
1053
+ cursor: grab;
1054
+ touch-action: none;
1055
+ box-shadow:
1056
+ 0 1px 0 rgba(255, 255, 255, 0.06) inset,
1057
+ 0 8px 24px -4px rgba(0, 0, 0, 0.45),
1058
+ 0 2px 6px rgba(0, 0, 0, 0.35);
1059
+ font: 600 14px/1 'Inter', system-ui, -apple-system, sans-serif;
1060
+ letter-spacing: 0.02em;
1061
+ display: flex;
1062
+ align-items: center;
1063
+ justify-content: center;
1064
+ z-index: 2147483646;
1065
+ transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease;
1066
+ opacity: 0.92;
1067
+ }
1068
+ .fab:hover {
1069
+ opacity: 1;
1070
+ transform: translateY(-1px);
1071
+ border-color: rgba(129, 140, 248, 0.4);
1072
+ box-shadow:
1073
+ 0 1px 0 rgba(255, 255, 255, 0.08) inset,
1074
+ 0 0 0 1px rgba(129, 140, 248, 0.3),
1075
+ 0 12px 28px -4px rgba(0, 0, 0, 0.5),
1076
+ 0 4px 10px rgba(0, 0, 0, 0.4);
1077
+ }
1078
+ .fab:active { cursor: grabbing; transform: translateY(0); }
1079
+ .fab[data-dragging="1"] {
1080
+ cursor: grabbing;
1081
+ opacity: 1;
1082
+ transition: none;
1083
+ box-shadow:
1084
+ 0 1px 0 rgba(255, 255, 255, 0.1) inset,
1085
+ 0 0 0 1px rgba(129, 140, 248, 0.45),
1086
+ 0 18px 36px -6px rgba(0, 0, 0, 0.6);
1087
+ }
1088
+ .fab[data-state="active"] {
1089
+ background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
1090
+ border-color: rgba(248, 113, 113, 0.4);
1091
+ opacity: 1;
1092
+ }
1093
+ .fab[data-state="flash"] {
1094
+ background: linear-gradient(135deg, #10b981 0%, #047857 100%);
1095
+ border-color: rgba(52, 211, 153, 0.4);
1096
+ opacity: 1;
1097
+ }
1098
+
1099
+ .info-card {
1100
+ position: fixed;
1101
+ width: 300px;
1102
+ background: rgba(17, 17, 20, 0.92);
1103
+ color: #e4e4e7;
1104
+ border: 1px solid rgba(255, 255, 255, 0.08);
1105
+ border-radius: 12px;
1106
+ box-shadow:
1107
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
1108
+ 0 18px 50px -8px rgba(0, 0, 0, 0.6),
1109
+ 0 4px 12px rgba(0, 0, 0, 0.4);
1110
+ backdrop-filter: blur(14px) saturate(180%);
1111
+ -webkit-backdrop-filter: blur(14px) saturate(180%);
1112
+ padding: 14px;
1113
+ display: none;
1114
+ flex-direction: column;
1115
+ gap: 10px;
1116
+ z-index: 2147483646;
1117
+ font: 13px/1.4 'Inter', system-ui, -apple-system, sans-serif;
1118
+ animation: fade-in 180ms ease-out;
1119
+ }
1120
+ @keyframes fade-in {
1121
+ from { opacity: 0; transform: translateY(2px); }
1122
+ to { opacity: 1; transform: translateY(0); }
1123
+ }
1124
+ .info-card .bar {
1125
+ display: flex;
1126
+ align-items: center;
1127
+ gap: 8px;
1128
+ padding-bottom: 10px;
1129
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
1130
+ }
1131
+ .info-card .bar .proj {
1132
+ font-weight: 600;
1133
+ flex: 1;
1134
+ min-width: 0;
1135
+ overflow: hidden;
1136
+ text-overflow: ellipsis;
1137
+ white-space: nowrap;
1138
+ color: #f4f4f5;
1139
+ }
1140
+ .info-card .dot {
1141
+ width: 8px;
1142
+ height: 8px;
1143
+ border-radius: 50%;
1144
+ background: #71717a;
1145
+ flex-shrink: 0;
1146
+ }
1147
+ .info-card .dot[data-state="open"] {
1148
+ background: #34d399;
1149
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
1150
+ animation: pulse 2s ease-in-out infinite;
1151
+ }
1152
+ .info-card .dot[data-state="connecting"] {
1153
+ background: #fbbf24;
1154
+ animation: blink 0.6s ease-in-out infinite;
1155
+ }
1156
+ .info-card .conn { color: #a1a1aa; font-size: 11px; flex-shrink: 0; }
1157
+ .info-card .close-btn {
1158
+ background: none;
1159
+ border: none;
1160
+ color: #71717a;
1161
+ cursor: pointer;
1162
+ padding: 0;
1163
+ width: 18px;
1164
+ height: 18px;
1165
+ font-size: 16px;
1166
+ line-height: 1;
1167
+ flex-shrink: 0;
1168
+ border-radius: 4px;
1169
+ transition: color 0.12s ease, background 0.12s ease;
1170
+ }
1171
+ .info-card .close-btn:hover { color: #f4f4f5; background: rgba(255, 255, 255, 0.06); }
1172
+
1173
+ .info-card .rows { display: flex; flex-direction: column; gap: 6px; }
1174
+ .info-card .row {
1175
+ display: flex;
1176
+ align-items: center;
1177
+ gap: 8px;
1178
+ font-size: 12px;
1179
+ }
1180
+ .info-card .row .key {
1181
+ color: #71717a;
1182
+ width: 60px;
1183
+ flex-shrink: 0;
1184
+ font-size: 10px;
1185
+ text-transform: uppercase;
1186
+ letter-spacing: 0.06em;
1187
+ font-weight: 500;
1188
+ }
1189
+ .info-card .pill {
1190
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
1191
+ font-size: 11px;
1192
+ background: rgba(255, 255, 255, 0.04);
1193
+ border-radius: 5px;
1194
+ padding: 3px 7px;
1195
+ color: #d4d4d8;
1196
+ cursor: pointer;
1197
+ border: 1px solid rgba(255, 255, 255, 0.04);
1198
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
1199
+ user-select: none;
1200
+ white-space: nowrap;
1201
+ overflow: hidden;
1202
+ text-overflow: ellipsis;
1203
+ max-width: 100%;
1204
+ min-width: 0;
1205
+ }
1206
+ .info-card .pill:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.12); color: #f4f4f5; }
1207
+ .info-card .pill[data-copied="1"] {
1208
+ background: rgba(52, 211, 153, 0.12);
1209
+ color: #34d399;
1210
+ border-color: rgba(52, 211, 153, 0.3);
1211
+ }
1212
+ .info-card .pill.url {
1213
+ cursor: default;
1214
+ background: transparent;
1215
+ color: #a1a1aa;
1216
+ padding: 0;
1217
+ border-color: transparent;
1218
+ }
1219
+ .info-card .pill.url:hover { background: transparent; border-color: transparent; }
1220
+
1221
+ .info-card .actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
1222
+ .info-card .primary {
1223
+ background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
1224
+ color: #fff;
1225
+ border: 1px solid rgba(129, 140, 248, 0.4);
1226
+ border-radius: 8px;
1227
+ padding: 10px 12px;
1228
+ font: 600 13px/1.2 'Inter', system-ui, sans-serif;
1229
+ cursor: pointer;
1230
+ text-align: left;
1231
+ display: flex;
1232
+ align-items: center;
1233
+ gap: 10px;
1234
+ transition: filter 0.15s ease, box-shadow 0.15s ease;
1235
+ box-shadow: 0 4px 12px -2px rgba(99, 102, 241, 0.3);
1236
+ }
1237
+ .info-card .primary:hover { filter: brightness(1.1); box-shadow: 0 6px 16px -2px rgba(99, 102, 241, 0.4); }
1238
+ .info-card .primary .icon { font-size: 16px; }
1239
+ .info-card .primary .label { flex: 1; }
1240
+ .info-card .primary .hint {
1241
+ font-size: 11px;
1242
+ color: rgba(255, 255, 255, 0.7);
1243
+ font-weight: 400;
1244
+ }
1245
+
1246
+ .info-card .secondary {
1247
+ background: rgba(255, 255, 255, 0.03);
1248
+ color: #d4d4d8;
1249
+ border: 1px solid rgba(255, 255, 255, 0.08);
1250
+ border-radius: 8px;
1251
+ padding: 9px 12px;
1252
+ font: 500 12px/1.2 'Inter', system-ui, sans-serif;
1253
+ cursor: pointer;
1254
+ display: flex;
1255
+ align-items: center;
1256
+ gap: 8px;
1257
+ transition: background 0.12s ease, border-color 0.12s ease;
1258
+ }
1259
+ .info-card .secondary:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.16); color: #f4f4f5; }
1260
+ .info-card .secondary[data-copied="1"] {
1261
+ background: rgba(52, 211, 153, 0.12);
1262
+ color: #34d399;
1263
+ border-color: rgba(52, 211, 153, 0.3);
1264
+ }
1265
+
1266
+ .picker-bar {
1267
+ position: fixed;
1268
+ top: 12px;
1269
+ left: 50%;
1270
+ transform: translateX(-50%);
1271
+ background: #111827;
1272
+ color: #fff;
1273
+ border-radius: 8px;
1274
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
1275
+ padding: 8px 14px;
1276
+ display: none;
1277
+ align-items: center;
1278
+ gap: 14px;
1279
+ z-index: 2147483646;
1280
+ font: 13px/1 system-ui, sans-serif;
1281
+ }
1282
+ .picker-bar .label { display: flex; align-items: center; gap: 8px; }
1283
+ .picker-bar .hint { color: #9ca3af; font-size: 11px; }
1284
+ .picker-bar button {
1285
+ background: rgba(255, 255, 255, 0.1);
1286
+ color: #fff;
1287
+ border: none;
1288
+ border-radius: 5px;
1289
+ padding: 4px 10px;
1290
+ cursor: pointer;
1291
+ font: inherit;
1292
+ }
1293
+ .picker-bar button:hover { background: rgba(255, 255, 255, 0.18); }
1294
+
1295
+ .highlight {
1296
+ position: absolute;
1297
+ pointer-events: none;
1298
+ border: 2px solid #2563eb;
1299
+ background: rgba(37, 99, 235, 0.1);
1300
+ border-radius: 3px;
1301
+ z-index: 2147483645;
1302
+ display: none;
1303
+ box-sizing: border-box;
1304
+ }
1305
+
1306
+ .question {
1307
+ position: fixed;
1308
+ width: 320px;
1309
+ background: rgba(17, 17, 20, 0.92);
1310
+ color: #e4e4e7;
1311
+ border: 1px solid rgba(255, 255, 255, 0.08);
1312
+ border-radius: 12px;
1313
+ box-shadow:
1314
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
1315
+ 0 18px 50px -8px rgba(0, 0, 0, 0.6);
1316
+ backdrop-filter: blur(14px) saturate(180%);
1317
+ -webkit-backdrop-filter: blur(14px) saturate(180%);
1318
+ padding: 14px;
1319
+ display: none;
1320
+ flex-direction: column;
1321
+ gap: 10px;
1322
+ z-index: 2147483646;
1323
+ font: 13px/1.4 'Inter', system-ui, sans-serif;
1324
+ animation: fade-in 180ms ease-out;
1325
+ }
1326
+ .question h3 { margin: 0; font-size: 13px; font-weight: 600; }
1327
+ .question .info {
1328
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1329
+ font-size: 11px;
1330
+ background: #f3f4f6;
1331
+ border-radius: 6px;
1332
+ padding: 6px 8px;
1333
+ color: #374151;
1334
+ word-break: break-all;
1335
+ max-height: 60px;
1336
+ overflow: auto;
1337
+ }
1338
+ .question textarea {
1339
+ width: 100%;
1340
+ box-sizing: border-box;
1341
+ min-height: 80px;
1342
+ font: inherit;
1343
+ border: 1px solid #d1d5db;
1344
+ border-radius: 6px;
1345
+ padding: 8px;
1346
+ resize: vertical;
1347
+ outline: none;
1348
+ }
1349
+ .question textarea:focus { border-color: #2563eb; }
1350
+ .question .row { display: flex; justify-content: flex-end; gap: 8px; }
1351
+ .question button {
1352
+ font: inherit;
1353
+ border-radius: 6px;
1354
+ padding: 6px 12px;
1355
+ cursor: pointer;
1356
+ border: 1px solid transparent;
1357
+ }
1358
+ .question .cancel { background: #fff; border-color: #d1d5db; color: #374151; }
1359
+ .question .submit { background: #111827; color: #fff; }
1360
+ .question .submit:hover { background: #000; }
1361
+
1362
+ .question .attach-area {
1363
+ display: flex;
1364
+ flex-direction: column;
1365
+ gap: 6px;
1366
+ margin-top: -2px;
1367
+ }
1368
+ .question .add-shot {
1369
+ display: inline-flex;
1370
+ align-self: flex-start;
1371
+ align-items: center;
1372
+ gap: 6px;
1373
+ background: #f3f4f6;
1374
+ color: #374151;
1375
+ border: 1px dashed #d1d5db;
1376
+ border-radius: 6px;
1377
+ padding: 6px 10px;
1378
+ font: 500 12px/1.2 system-ui, sans-serif;
1379
+ cursor: pointer;
1380
+ transition: background 0.12s ease, border-color 0.12s ease;
1381
+ }
1382
+ .question .add-shot:hover {
1383
+ background: #e5e7eb;
1384
+ border-color: #9ca3af;
1385
+ border-style: solid;
1386
+ }
1387
+ .question .thumb-preview {
1388
+ display: flex;
1389
+ gap: 8px;
1390
+ align-items: stretch;
1391
+ background: #f9fafb;
1392
+ border: 1px solid #e5e7eb;
1393
+ border-radius: 6px;
1394
+ padding: 6px;
1395
+ }
1396
+ .question .thumb-preview img {
1397
+ max-width: 120px;
1398
+ max-height: 90px;
1399
+ object-fit: contain;
1400
+ border-radius: 4px;
1401
+ background: #fff;
1402
+ display: block;
1403
+ }
1404
+ .question .thumb-preview .thumb-meta {
1405
+ display: flex;
1406
+ flex-direction: column;
1407
+ justify-content: space-between;
1408
+ font-size: 11px;
1409
+ color: #6b7280;
1410
+ flex: 1;
1411
+ min-width: 0;
1412
+ }
1413
+ .question .thumb-preview .thumb-meta button {
1414
+ background: transparent;
1415
+ border: 1px solid transparent;
1416
+ color: #6b7280;
1417
+ font: 500 11px/1.2 system-ui, sans-serif;
1418
+ padding: 3px 6px;
1419
+ border-radius: 4px;
1420
+ cursor: pointer;
1421
+ text-align: left;
1422
+ }
1423
+ .question .thumb-preview .thumb-meta button:hover {
1424
+ background: #f3f4f6;
1425
+ color: #111;
1426
+ border-color: #d1d5db;
1427
+ }
1428
+ .question .thumb-preview .thumb-remove:hover {
1429
+ background: #fee2e2;
1430
+ color: #991b1b;
1431
+ border-color: #fecaca;
1432
+ }
1433
+
1434
+ .reports-card {
1435
+ position: fixed;
1436
+ width: 320px;
1437
+ max-height: 480px;
1438
+ background: rgba(17, 17, 20, 0.92);
1439
+ color: #e4e4e7;
1440
+ border: 1px solid rgba(255, 255, 255, 0.08);
1441
+ border-radius: 12px;
1442
+ box-shadow:
1443
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
1444
+ 0 18px 50px -8px rgba(0, 0, 0, 0.6);
1445
+ backdrop-filter: blur(14px) saturate(180%);
1446
+ -webkit-backdrop-filter: blur(14px) saturate(180%);
1447
+ display: none;
1448
+ flex-direction: column;
1449
+ z-index: 2147483646;
1450
+ font: 13px/1.4 'Inter', system-ui, -apple-system, sans-serif;
1451
+ overflow: hidden;
1452
+ animation: fade-in 180ms ease-out;
1453
+ }
1454
+ .reports-card .bar {
1455
+ display: flex;
1456
+ align-items: center;
1457
+ gap: 8px;
1458
+ padding: 12px 14px 10px;
1459
+ border-bottom: 1px solid #f3f4f6;
1460
+ }
1461
+ .reports-card .back-btn,
1462
+ .reports-card .refresh-btn,
1463
+ .reports-card .close-btn {
1464
+ background: none;
1465
+ border: none;
1466
+ color: #6b7280;
1467
+ cursor: pointer;
1468
+ padding: 2px 4px;
1469
+ font-size: 15px;
1470
+ line-height: 1;
1471
+ }
1472
+ .reports-card .back-btn:hover,
1473
+ .reports-card .refresh-btn:hover,
1474
+ .reports-card .close-btn:hover { color: #111; }
1475
+ .reports-card .title { flex: 1; font-weight: 600; }
1476
+ .reports-card .count { color: #6b7280; font-size: 11px; }
1477
+ .reports-card .list {
1478
+ overflow-y: auto;
1479
+ padding: 6px 0;
1480
+ flex: 1;
1481
+ }
1482
+ .reports-card .loading,
1483
+ .reports-card .empty {
1484
+ color: #6b7280;
1485
+ padding: 24px 14px;
1486
+ text-align: center;
1487
+ font-size: 12px;
1488
+ }
1489
+ .reports-card .empty.error { color: #b91c1c; }
1490
+ .reports-card .task-row {
1491
+ padding: 10px 14px;
1492
+ border-bottom: 1px solid #f9fafb;
1493
+ display: flex;
1494
+ flex-direction: column;
1495
+ gap: 4px;
1496
+ }
1497
+ .reports-card .task-row:last-child { border-bottom: none; }
1498
+ .reports-card .task-row .status {
1499
+ font-size: 11px;
1500
+ color: #6b7280;
1501
+ font-weight: 500;
1502
+ }
1503
+ .reports-card .task-row[data-status="pending"] .status { color: #b45309; }
1504
+ .reports-card .task-row[data-status="claimed"] .status { color: #1e40af; }
1505
+ .reports-card .task-row[data-status="resolved"] .status { color: #047857; }
1506
+ .reports-card .task-row .ago { color: #9ca3af; font-weight: 400; }
1507
+ .reports-card .task-row .q {
1508
+ color: #111;
1509
+ font-size: 13px;
1510
+ line-height: 1.45;
1511
+ word-break: break-word;
1512
+ }
1513
+ .reports-card .task-row .note {
1514
+ background: #f0fdf4;
1515
+ border-left: 3px solid #16a34a;
1516
+ padding: 4px 8px;
1517
+ border-radius: 3px;
1518
+ color: #047857;
1519
+ font-size: 12px;
1520
+ margin-top: 2px;
1521
+ }
1522
+ .reports-card .task-row .meta {
1523
+ color: #6b7280;
1524
+ font-size: 11px;
1525
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1526
+ word-break: break-all;
1527
+ }
1528
+ .reports-card .task-row .edit-area {
1529
+ width: 100%;
1530
+ box-sizing: border-box;
1531
+ min-height: 50px;
1532
+ border: 1px solid #d1d5db;
1533
+ border-radius: 5px;
1534
+ padding: 6px 8px;
1535
+ font: inherit;
1536
+ resize: vertical;
1537
+ }
1538
+ .reports-card .task-row .row-actions {
1539
+ display: flex;
1540
+ gap: 6px;
1541
+ margin-top: 4px;
1542
+ }
1543
+ .reports-card .task-row .rl-btn {
1544
+ background: #f3f4f6;
1545
+ color: #374151;
1546
+ border: 1px solid transparent;
1547
+ border-radius: 5px;
1548
+ padding: 3px 8px;
1549
+ font: 500 11px/1.2 system-ui, sans-serif;
1550
+ cursor: pointer;
1551
+ transition: background 0.12s ease;
1552
+ }
1553
+ .reports-card .task-row .rl-btn:hover { background: #e5e7eb; }
1554
+ .reports-card .task-row .rl-btn.ghost {
1555
+ background: transparent;
1556
+ color: #9ca3af;
1557
+ }
1558
+ .reports-card .task-row .rl-btn.ghost:hover { background: #f3f4f6; color: #6b7280; }
1559
+ .reports-card .task-row .rl-btn.danger {
1560
+ background: #fee2e2;
1561
+ color: #991b1b;
1562
+ }
1563
+ .reports-card .task-row .rl-btn.danger:hover { background: #fecaca; }
1564
+
1565
+ @keyframes pulse {
1566
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
1567
+ 50% { box-shadow: 0 0 0 4px rgba(16, 185, 129, 0); }
1568
+ }
1569
+ @keyframes blink {
1570
+ 0%, 100% { opacity: 1; }
1571
+ 50% { opacity: 0.4; }
1572
+ }
1573
+
1574
+ /* ── Annotate modal ── */
1575
+ .annotate-modal {
1576
+ position: fixed;
1577
+ inset: 0;
1578
+ background: rgba(0,0,0,0.72);
1579
+ z-index: 2147483646;
1580
+ display: none;
1581
+ flex-direction: column;
1582
+ align-items: center;
1583
+ justify-content: center;
1584
+ gap: 10px;
1585
+ padding: 16px;
1586
+ box-sizing: border-box;
1587
+ }
1588
+ .annotate-toolbar {
1589
+ display: flex;
1590
+ align-items: center;
1591
+ gap: 8px;
1592
+ background: #1f2937;
1593
+ border-radius: 8px;
1594
+ padding: 6px 10px;
1595
+ }
1596
+ .annotate-toolbar button {
1597
+ background: rgba(255,255,255,0.08);
1598
+ border: 1px solid transparent;
1599
+ border-radius: 5px;
1600
+ color: #e5e7eb;
1601
+ cursor: pointer;
1602
+ font: 12px/1 system-ui, sans-serif;
1603
+ padding: 4px 9px;
1604
+ transition: background 0.12s ease;
1605
+ }
1606
+ .annotate-toolbar button:hover { background: rgba(255,255,255,0.15); }
1607
+ .annotate-toolbar button[data-active="1"] {
1608
+ background: rgba(255,255,255,0.22);
1609
+ border-color: rgba(255,255,255,0.3);
1610
+ }
1611
+ .annotate-swatch {
1612
+ width: 16px;
1613
+ height: 16px;
1614
+ border-radius: 50%;
1615
+ cursor: pointer;
1616
+ border: 2px solid transparent;
1617
+ outline: none;
1618
+ flex-shrink: 0;
1619
+ display: inline-block;
1620
+ }
1621
+ .annotate-swatch[data-active="1"] { border-color: #fff; }
1622
+ .annotate-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.15); }
1623
+ .annotate-canvas-wrap {
1624
+ max-width: 100%;
1625
+ max-height: calc(100vh - 120px);
1626
+ overflow: auto;
1627
+ border-radius: 6px;
1628
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
1629
+ }
1630
+ .annotate-canvas {
1631
+ display: block;
1632
+ cursor: crosshair;
1633
+ max-width: 100%;
1634
+ }
1635
+ .annotate-hint {
1636
+ color: #9ca3af;
1637
+ font: 11px system-ui, sans-serif;
1638
+ }
1639
+ .annotate-actions {
1640
+ display: flex;
1641
+ gap: 8px;
1642
+ }
1643
+ .annotate-actions button {
1644
+ border-radius: 7px;
1645
+ padding: 8px 16px;
1646
+ font: 500 13px system-ui, sans-serif;
1647
+ cursor: pointer;
1648
+ border: 1px solid transparent;
1649
+ }
1650
+ .annotate-actions .ann-cancel {
1651
+ background: rgba(255,255,255,0.08);
1652
+ color: #d1d5db;
1653
+ border-color: rgba(255,255,255,0.15);
1654
+ }
1655
+ .annotate-actions .ann-cancel:hover { background: rgba(255,255,255,0.14); }
1656
+ .annotate-actions .ann-done {
1657
+ background: #2563eb;
1658
+ color: #fff;
1659
+ }
1660
+ .annotate-actions .ann-done:hover { background: #1d4ed8; }
1661
+
1662
+ /* thumbnail in reports */
1663
+ .task-thumb {
1664
+ max-height: 80px;
1665
+ max-width: 120px;
1666
+ border-radius: 4px;
1667
+ cursor: pointer;
1668
+ border: 1px solid #e5e7eb;
1669
+ object-fit: contain;
1670
+ margin-top: 4px;
1671
+ }
1672
+ /* lightbox */
1673
+ .thumb-lightbox {
1674
+ position: fixed;
1675
+ inset: 0;
1676
+ background: rgba(0,0,0,0.8);
1677
+ z-index: 2147483647;
1678
+ display: flex;
1679
+ align-items: center;
1680
+ justify-content: center;
1681
+ cursor: zoom-out;
1682
+ }
1683
+ .thumb-lightbox img {
1684
+ max-width: 90vw;
1685
+ max-height: 90vh;
1686
+ border-radius: 8px;
1687
+ box-shadow: 0 16px 48px rgba(0,0,0,0.5);
1688
+ }
1689
+ `;
1690
+ return style;
1691
+ }
1692
+ function buildFab() {
1693
+ const btn = document.createElement('button');
1694
+ btn.className = 'fab';
1695
+ btn.dataset.state = 'idle';
1696
+ btn.title = 'Harness-FE · click to open (Cmd+Shift+H)';
1697
+ btn.setAttribute('aria-label', 'Open Harness-FE panel');
1698
+ btn.textContent = 'H';
1699
+ return btn;
1700
+ }
1701
+ function buildInfoCard() {
1702
+ const card = document.createElement('div');
1703
+ card.className = 'info-card';
1704
+ card.innerHTML = `
1705
+ <div class="bar">
1706
+ <span class="dot" data-role="dot"></span>
1707
+ <span class="proj" data-role="project"></span>
1708
+ <span class="conn" data-role="conn"></span>
1709
+ <button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
1710
+ </div>
1711
+ <div class="rows">
1712
+ <div class="row"><span class="key">build</span><span class="pill" data-role="build" title="Click to copy"></span></div>
1713
+ <div class="row"><span class="key">session</span><span class="pill" data-role="session" title="Click to copy"></span></div>
1714
+ <div class="row"><span class="key">tab</span><span class="pill" data-role="tab" title="Click to copy"></span></div>
1715
+ <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
1716
+ </div>
1717
+ <div class="actions">
1718
+ <button class="primary" data-role="report" type="button">
1719
+ <span class="icon">🎯</span>
1720
+ <span class="label">Report a problem</span>
1721
+ <span class="hint">Pick an element →</span>
1722
+ </button>
1723
+ <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
1724
+ <span class="icon">↗</span>
1725
+ <span>Open dashboard</span>
1726
+ </button>
1727
+ <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1728
+ <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1729
+ </div>
1730
+ `;
1731
+ return card;
1732
+ }
1733
+ function buildReportsCard() {
1734
+ const card = document.createElement('div');
1735
+ card.className = 'reports-card';
1736
+ card.innerHTML = `
1737
+ <div class="bar">
1738
+ <button class="back-btn" data-role="back" title="Back" type="button">←</button>
1739
+ <span class="title">My reports</span>
1740
+ <span class="count" data-role="count"></span>
1741
+ <button class="refresh-btn" data-role="refresh" title="Refresh" type="button">↻</button>
1742
+ <button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
1743
+ </div>
1744
+ <div class="list" data-role="list">
1745
+ <div class="loading" data-role="loading">Loading…</div>
1746
+ </div>
1747
+ `;
1748
+ return card;
1749
+ }
1750
+ function buildPickerBar() {
1751
+ const bar = document.createElement('div');
1752
+ bar.className = 'picker-bar';
1753
+ bar.innerHTML = `
1754
+ <span class="label">🎯 Click an element to flag it</span>
1755
+ <span class="hint">esc to cancel</span>
1756
+ <button data-role="cancel" type="button">Cancel</button>
1757
+ `;
1758
+ return bar;
1759
+ }
1760
+ function buildAnnotateModal() {
1761
+ const modal = document.createElement('div');
1762
+ modal.className = 'annotate-modal';
1763
+ modal.innerHTML = `
1764
+ <div class="annotate-toolbar">
1765
+ <button data-role="annotate-tool" data-tool="arrow" title="Arrow tool">↗ Arrow</button>
1766
+ <button data-role="annotate-tool" data-tool="text" title="Text tool">T Text</button>
1767
+ <button data-role="annotate-undo" title="Undo last stroke">↩ Undo</button>
1768
+ <span class="annotate-sep"></span>
1769
+ <span class="annotate-swatch" data-role="annotate-color" data-color="red"
1770
+ style="background:#ef4444" title="Red"></span>
1771
+ <span class="annotate-swatch" data-role="annotate-color" data-color="blue"
1772
+ style="background:#3b82f6" title="Blue"></span>
1773
+ <span class="annotate-swatch" data-role="annotate-color" data-color="yellow"
1774
+ style="background:#eab308" title="Yellow"></span>
1775
+ <span class="annotate-swatch" data-role="annotate-color" data-color="green"
1776
+ style="background:#22c55e" title="Green"></span>
1777
+ <span class="annotate-swatch" data-role="annotate-color" data-color="black"
1778
+ style="background:#111827" title="Black"></span>
1779
+ </div>
1780
+ <div class="annotate-canvas-wrap"></div>
1781
+ <div class="annotate-hint">Draw arrows or add text, then click Done · Esc to go back</div>
1782
+ <div class="annotate-actions">
1783
+ <button class="ann-cancel" data-role="annotate-cancel" type="button">Cancel</button>
1784
+ <button class="ann-done" data-role="annotate-done" type="button">Done</button>
1785
+ </div>
1786
+ `;
1787
+ return modal;
1788
+ }
1789
+ function buildQuestionPanel() {
1790
+ const panel = document.createElement('div');
1791
+ panel.className = 'question';
1792
+ panel.innerHTML = `
1793
+ <h3>What's wrong with this element?</h3>
1794
+ <div class="info" data-role="info"></div>
1795
+ <textarea placeholder="Describe the problem, expected behavior, or what the agent should do…"></textarea>
1796
+ <div class="attach-area" data-role="attach-area">
1797
+ <button class="add-shot" data-role="add-shot" type="button">📷 Add screenshot</button>
1798
+ <div class="thumb-preview" data-role="thumb-preview" style="display: none;">
1799
+ <img data-role="thumb-img" alt="screenshot preview" />
1800
+ <div class="thumb-meta">
1801
+ <span data-role="thumb-dims"></span>
1802
+ <button class="thumb-edit" data-role="thumb-edit" type="button" title="Re-annotate">✎ Edit</button>
1803
+ <button class="thumb-remove" data-role="thumb-remove" type="button" title="Remove">×</button>
1804
+ </div>
1805
+ </div>
1806
+ </div>
1807
+ <div class="row">
1808
+ <button class="cancel" data-role="cancel" type="button">Cancel</button>
1809
+ <button class="submit" data-role="submit" type="button">Submit</button>
1810
+ </div>
1811
+ `;
1812
+ return panel;
1813
+ }
1814
+ function buildHighlight() {
1815
+ const div = document.createElement('div');
1816
+ div.className = 'highlight';
1817
+ return div;
1818
+ }
1819
+ // ─── Element / payload helpers (unchanged from annotation.ts) ────────────
1820
+ function describeElement(el) {
1821
+ const tag = el.tagName.toLowerCase();
1822
+ const comp = el.getAttribute('data-morphix-comp');
1823
+ const loc = el.getAttribute('data-morphix-loc');
1824
+ const aria = el.getAttribute('aria-label');
1825
+ const parts = [tag];
1826
+ if (comp)
1827
+ parts.push(`comp=${comp}`);
1828
+ if (loc)
1829
+ parts.push(`loc=${loc}`);
1830
+ if (aria)
1831
+ parts.push(`aria="${aria}"`);
1832
+ return parts.join(' · ');
1833
+ }
1834
+ function buildPayload(el, question, attachment) {
1835
+ const rect = el.getBoundingClientRect();
1836
+ return {
1837
+ question,
1838
+ url: location.href,
1839
+ selector: {
1840
+ comp: el.getAttribute('data-morphix-comp') ?? undefined,
1841
+ loc: el.getAttribute('data-morphix-loc') ?? undefined,
1842
+ css: buildCssPath(el),
1843
+ },
1844
+ element: {
1845
+ tag: el.tagName.toLowerCase(),
1846
+ outerHTML: truncate(stripInternalAttrs(el.outerHTML), MAX_OUTER_HTML),
1847
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1848
+ },
1849
+ attachments: attachment ? [attachment] : undefined,
1850
+ };
1851
+ }
1852
+ function truncate(s, n) {
1853
+ return s.length <= n ? s : `${s.slice(0, n)}…`;
1854
+ }
1855
+ /**
1856
+ * Best-effort CSS path. Depth cap 12, id anchor short-circuits, ` >>> `
1857
+ * separates shadow-DOM boundaries.
1858
+ */
1859
+ export function buildCssPath(el) {
1860
+ const segments = [];
1861
+ let current = [];
1862
+ let cur = el;
1863
+ let depth = 0;
1864
+ const MAX_DEPTH = 12;
1865
+ while (cur && cur.nodeType === 1 && depth < MAX_DEPTH) {
1866
+ const node = cur;
1867
+ const tag = node.tagName.toLowerCase();
1868
+ if (node.id) {
1869
+ current.unshift(`${tag}#${cssEscape(node.id)}`);
1870
+ break;
1871
+ }
1872
+ const parent = node.parentNode;
1873
+ if (parent instanceof ShadowRoot) {
1874
+ segments.unshift(current.join(' > '));
1875
+ current = [tag];
1876
+ cur = parent.host;
1877
+ depth++;
1878
+ continue;
1879
+ }
1880
+ const cls = node.classList?.[0];
1881
+ const seg = cls ? `${tag}.${cssEscape(cls)}` : tag;
1882
+ current.unshift(seg);
1883
+ const parentEl = node.parentElement;
1884
+ if (!parentEl)
1885
+ break;
1886
+ const siblings = Array.from(parentEl.children).filter((c) => c.tagName === node.tagName);
1887
+ if (siblings.length > 1) {
1888
+ const idx = siblings.indexOf(node);
1889
+ current[0] = `${current[0]}:nth-of-type(${idx + 1})`;
1890
+ }
1891
+ cur = parentEl;
1892
+ depth++;
1893
+ }
1894
+ if (current.length > 0)
1895
+ segments.unshift(current.join(' > '));
1896
+ return segments.join(' >>> ');
1897
+ }
1898
+ function cssEscape(s) {
1899
+ if (typeof CSS !== 'undefined' && CSS.escape)
1900
+ return CSS.escape(s);
1901
+ return s.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
1902
+ }
1903
+ // ─── UI helpers ──────────────────────────────────────────────────────────
1904
+ function abbr(id) {
1905
+ if (id.length <= 12)
1906
+ return id;
1907
+ return id.slice(0, 8);
1908
+ }
1909
+ function shortenUrl(url) {
1910
+ try {
1911
+ const u = new URL(url);
1912
+ const path = u.pathname + u.search;
1913
+ return path.length > 40 ? path.slice(0, 38) + '…' : path;
1914
+ }
1915
+ catch {
1916
+ return url;
1917
+ }
1918
+ }
1919
+ function flashFab(fab) {
1920
+ fab.dataset.state = 'flash';
1921
+ setTimeout(() => {
1922
+ fab.dataset.state = 'idle';
1923
+ }, 600);
1924
+ }
1925
+ function escapeHtml(s) {
1926
+ return s
1927
+ .replace(/&/g, '&amp;')
1928
+ .replace(/</g, '&lt;')
1929
+ .replace(/>/g, '&gt;')
1930
+ .replace(/"/g, '&quot;')
1931
+ .replace(/'/g, '&#39;');
1932
+ }
1933
+ function escapeAttr(s) {
1934
+ return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1935
+ }
1936
+ function formatAgo(ts) {
1937
+ const diff = Date.now() - ts;
1938
+ if (diff < 60_000)
1939
+ return 'just now';
1940
+ if (diff < 3_600_000)
1941
+ return `${Math.floor(diff / 60_000)}m ago`;
1942
+ if (diff < 86_400_000)
1943
+ return `${Math.floor(diff / 3_600_000)}h ago`;
1944
+ const days = Math.floor(diff / 86_400_000);
1945
+ return days < 30 ? `${days}d ago` : new Date(ts).toLocaleDateString();
1946
+ }