browsertrack 0.0.1 → 0.1.0

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 (101) hide show
  1. package/README.md +143 -0
  2. package/dist/chunk-6A7FFDIB.js +221 -0
  3. package/dist/chunk-6A7FFDIB.js.map +1 -0
  4. package/dist/chunk-ANMR5WBH.js +659 -0
  5. package/dist/chunk-ANMR5WBH.js.map +1 -0
  6. package/dist/chunk-BSKNG4V7.js +1199 -0
  7. package/dist/chunk-BSKNG4V7.js.map +1 -0
  8. package/dist/chunk-X7C5CHBO.js +2080 -0
  9. package/dist/chunk-X7C5CHBO.js.map +1 -0
  10. package/dist/chunk-ZLNGOOH2.js +554 -0
  11. package/dist/chunk-ZLNGOOH2.js.map +1 -0
  12. package/dist/cli/index.js +2829 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/client/index.cjs +2218 -0
  15. package/dist/client/index.d.ts +197 -0
  16. package/dist/client/index.js +20 -0
  17. package/dist/client/index.js.map +1 -0
  18. package/dist/client.iife.js +309 -0
  19. package/dist/core/index.d.ts +78 -0
  20. package/dist/core/index.js +31 -0
  21. package/dist/core/index.js.map +1 -0
  22. package/dist/daemon/index.d.ts +58 -0
  23. package/dist/daemon/index.js +32 -0
  24. package/dist/daemon/index.js.map +1 -0
  25. package/dist/engine-DKloFjvs.d.ts +157 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +50 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/mcp/index.d.ts +11 -0
  30. package/dist/mcp/index.js +12 -0
  31. package/dist/mcp/index.js.map +1 -0
  32. package/dist/notes-CBvN91Wf.d.ts +260 -0
  33. package/dist/projects-CY8ungMt.d.ts +99 -0
  34. package/dist/server-CN-se8td.d.ts +55 -0
  35. package/examples/test-app/index.html +299 -0
  36. package/package.json +52 -7
  37. package/packages/cli/src/index.ts +413 -0
  38. package/packages/client/package.json +28 -0
  39. package/packages/client/src/breadcrumbs.ts +30 -0
  40. package/packages/client/src/client.ts +278 -0
  41. package/packages/client/src/commands/handler.ts +301 -0
  42. package/packages/client/src/config.ts +37 -0
  43. package/packages/client/src/index.ts +50 -0
  44. package/packages/client/src/interceptors/console.ts +68 -0
  45. package/packages/client/src/interceptors/interaction.ts +119 -0
  46. package/packages/client/src/interceptors/navigation.ts +93 -0
  47. package/packages/client/src/interceptors/network.ts +165 -0
  48. package/packages/client/src/interceptors/runtime.ts +65 -0
  49. package/packages/client/src/notes/inspector.ts +943 -0
  50. package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
  51. package/packages/client/src/screenshot/driver.ts +14 -0
  52. package/packages/client/src/transport/websocket.ts +179 -0
  53. package/packages/client/tsconfig.json +8 -0
  54. package/packages/core/dist/index.d.ts +317 -0
  55. package/packages/core/dist/index.js +221 -0
  56. package/packages/core/package.json +23 -0
  57. package/packages/core/src/fingerprint.ts +120 -0
  58. package/packages/core/src/index.ts +9 -0
  59. package/packages/core/src/redaction.ts +139 -0
  60. package/packages/core/src/selector.ts +77 -0
  61. package/packages/core/src/types/commands.ts +101 -0
  62. package/packages/core/src/types/events.ts +108 -0
  63. package/packages/core/src/types/incidents.ts +54 -0
  64. package/packages/core/src/types/notes.ts +106 -0
  65. package/packages/core/src/types/probes.ts +44 -0
  66. package/packages/core/src/types/projects.ts +20 -0
  67. package/packages/core/tsconfig.json +8 -0
  68. package/packages/daemon/src/config.ts +29 -0
  69. package/packages/daemon/src/incidents/engine.ts +211 -0
  70. package/packages/daemon/src/index.ts +18 -0
  71. package/packages/daemon/src/notes/engine.ts +92 -0
  72. package/packages/daemon/src/notes/verification.ts +191 -0
  73. package/packages/daemon/src/server/daemon.ts +112 -0
  74. package/packages/daemon/src/server/http.ts +165 -0
  75. package/packages/daemon/src/server/ws.ts +175 -0
  76. package/packages/daemon/src/session/manager.ts +126 -0
  77. package/packages/daemon/src/storage/db.ts +733 -0
  78. package/packages/daemon/src/storage/screenshot-store.ts +49 -0
  79. package/packages/daemon/src/verification/engine.ts +264 -0
  80. package/packages/mcp/src/handlers.ts +398 -0
  81. package/packages/mcp/src/index.ts +3 -0
  82. package/packages/mcp/src/server.ts +86 -0
  83. package/packages/mcp/src/tools.ts +236 -0
  84. package/src/index.ts +4 -0
  85. package/test/client/interceptors.test.ts +69 -0
  86. package/test/core/fingerprint.test.ts +53 -0
  87. package/test/core/notes.test.ts +66 -0
  88. package/test/core/redaction.test.ts +48 -0
  89. package/test/daemon/incident-engine.test.ts +98 -0
  90. package/test/daemon/notes-storage.test.ts +130 -0
  91. package/test/daemon/notes-verification.test.ts +135 -0
  92. package/test/daemon/storage.test.ts +123 -0
  93. package/test/daemon/verification-engine.test.ts +88 -0
  94. package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
  95. package/test/e2e/visual-notes-e2e.test.ts +205 -0
  96. package/test/mcp/handlers.test.ts +116 -0
  97. package/test/mcp/notes.test.ts +107 -0
  98. package/tsconfig.base.json +17 -0
  99. package/tsconfig.json +26 -0
  100. package/tsup.config.ts +54 -0
  101. package/vitest.config.ts +9 -0
@@ -0,0 +1,943 @@
1
+ import type { ElementContext, NoteTarget, RegionContext, VisualNote } from '../../../core/src/index.js';
2
+ import { getSemanticSelector, truncate } from '../../../core/src/index.js';
3
+ import type { ScreenshotDriver } from '../screenshot/driver.js';
4
+ import type { WebSocketTransport } from '../transport/websocket.js';
5
+
6
+ export type NoteInspectMode = 'element' | 'region' | 'page' | 'idle';
7
+
8
+ export interface InspectorOptions {
9
+ shortcut?: string; // e.g. "Alt+Click"
10
+ maskSelectors?: string[];
11
+ showToolbar?: boolean;
12
+ onNoteCreated?: (note: Partial<VisualNote>) => void;
13
+ }
14
+
15
+ /**
16
+ * Isolated Visual Note Inspector rendering in Shadow DOM.
17
+ * Supports:
18
+ * 1. Element selection (hover highlight & click)
19
+ * 2. Region / Area selection (drag-and-drop rectangle on screen)
20
+ * 3. Whole page note
21
+ * 4. Floating quick dock / toolbar in bottom-right corner
22
+ */
23
+ export class NoteInspector {
24
+ private transport: WebSocketTransport;
25
+ private screenshotDriver: ScreenshotDriver;
26
+ private options: InspectorOptions;
27
+
28
+ private container: HTMLDivElement | null = null;
29
+ private shadowRoot: ShadowRoot | null = null;
30
+ private highlightOverlay: HTMLDivElement | null = null;
31
+ private regionOverlay: HTMLDivElement | null = null;
32
+ private regionBox: HTMLDivElement | null = null;
33
+ private toolbarElement: HTMLDivElement | null = null;
34
+ private modalOverlay: HTMLDivElement | null = null;
35
+
36
+ private activeMode: NoteInspectMode = 'idle';
37
+ private hoveredElement: HTMLElement | null = null;
38
+ private selectedElement: HTMLElement | null = null;
39
+ private selectedRegion: RegionContext | null = null;
40
+
41
+ private isDraggingRegion = false;
42
+ private dragStartX = 0;
43
+ private dragStartY = 0;
44
+ private cleanups: (() => void)[] = [];
45
+
46
+ constructor(transport: WebSocketTransport, screenshotDriver: ScreenshotDriver, options: InspectorOptions = {}) {
47
+ this.transport = transport;
48
+ this.screenshotDriver = screenshotDriver;
49
+ this.options = {
50
+ shortcut: 'Alt+Click',
51
+ maskSelectors: ['input[type="password"]', '[data-sensitive]'],
52
+ showToolbar: true,
53
+ ...options,
54
+ };
55
+ }
56
+
57
+ public init(): void {
58
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
59
+
60
+ if (document.readyState === 'loading') {
61
+ document.addEventListener('DOMContentLoaded', () => {
62
+ this.ensureContainer();
63
+ });
64
+ } else {
65
+ this.ensureContainer();
66
+ }
67
+
68
+ this.setupListeners();
69
+ }
70
+
71
+ public setMode(mode: NoteInspectMode): void {
72
+ this.activeMode = mode;
73
+ this.updateToolbarState();
74
+
75
+ if (mode === 'element') {
76
+ this.hideRegionOverlay();
77
+ } else if (mode === 'region') {
78
+ this.hideHighlight();
79
+ this.showRegionOverlay();
80
+ } else if (mode === 'page') {
81
+ this.hideRegionOverlay();
82
+ this.hideHighlight();
83
+ this.openNoteEditor(document.body, 'page');
84
+ } else {
85
+ this.hideRegionOverlay();
86
+ this.hideHighlight();
87
+ }
88
+ }
89
+
90
+ private ensureContainer(): ShadowRoot | null {
91
+ if (typeof document === 'undefined') return null;
92
+
93
+ if (!this.container) {
94
+ this.container = document.createElement('div');
95
+ this.container.id = 'browsertrack-inspector-host';
96
+ this.container.style.cssText =
97
+ 'all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
98
+
99
+ this.shadowRoot = this.container.attachShadow({ mode: 'open' });
100
+
101
+ // Styles for highlight overlay, region drag box, floating toolbar and modal
102
+ const style = document.createElement('style');
103
+ style.textContent = `
104
+ :host {
105
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
106
+ color-scheme: dark;
107
+ -webkit-font-smoothing: antialiased;
108
+ }
109
+
110
+ /* 1. Element Highlight Overlay */
111
+ .bt-highlight {
112
+ position: fixed;
113
+ border: 2px solid #3b82f6;
114
+ background: rgba(59, 130, 246, 0.15);
115
+ border-radius: 4px;
116
+ pointer-events: none;
117
+ transition: all 0.05s ease-out;
118
+ z-index: 2147483640;
119
+ box-sizing: border-box;
120
+ }
121
+
122
+ .bt-badge {
123
+ position: absolute;
124
+ top: -24px;
125
+ left: 0;
126
+ background: #1e293b;
127
+ color: #38bdf8;
128
+ border: 1px solid #3b82f6;
129
+ font-size: 11px;
130
+ font-weight: 600;
131
+ padding: 2px 6px;
132
+ border-radius: 3px;
133
+ white-space: nowrap;
134
+ pointer-events: none;
135
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
136
+ }
137
+
138
+ /* 2. Region Selection Overlay */
139
+ .bt-region-overlay {
140
+ position: fixed;
141
+ inset: 0;
142
+ width: 100vw;
143
+ height: 100vh;
144
+ cursor: crosshair;
145
+ z-index: 2147483642;
146
+ pointer-events: auto;
147
+ background: rgba(15, 23, 42, 0.2);
148
+ }
149
+
150
+ .bt-region-box {
151
+ position: fixed;
152
+ border: 2px dashed #38bdf8;
153
+ background: rgba(56, 189, 248, 0.2);
154
+ box-sizing: border-box;
155
+ pointer-events: none;
156
+ z-index: 2147483643;
157
+ }
158
+
159
+ .bt-region-badge {
160
+ position: absolute;
161
+ top: -24px;
162
+ left: 0;
163
+ background: #0f172a;
164
+ color: #38bdf8;
165
+ border: 1px solid #0284c7;
166
+ font-size: 11px;
167
+ font-weight: 600;
168
+ padding: 2px 6px;
169
+ border-radius: 3px;
170
+ white-space: nowrap;
171
+ pointer-events: none;
172
+ }
173
+
174
+ /* 3. Floating Quick Toolbar */
175
+ .bt-toolbar {
176
+ position: fixed;
177
+ bottom: 20px;
178
+ right: 20px;
179
+ background: #1e293b;
180
+ border: 1px solid #334155;
181
+ border-radius: 30px;
182
+ padding: 4px 6px;
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 4px;
186
+ box-shadow: 0 10px 15px -3px rgba(0,0,0,0.5), 0 4px 6px -4px rgba(0,0,0,0.5);
187
+ pointer-events: auto;
188
+ z-index: 2147483646;
189
+ user-select: none;
190
+ transition: all 0.2s ease;
191
+ }
192
+
193
+ .bt-toolbar-btn {
194
+ background: transparent;
195
+ border: none;
196
+ color: #94a3b8;
197
+ font-size: 12px;
198
+ font-weight: 500;
199
+ padding: 6px 10px;
200
+ border-radius: 20px;
201
+ cursor: pointer;
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 5px;
205
+ transition: all 0.15s ease;
206
+ }
207
+
208
+ .bt-toolbar-btn:hover {
209
+ background: #334155;
210
+ color: #f8fafc;
211
+ }
212
+
213
+ .bt-toolbar-btn.active {
214
+ background: #2563eb;
215
+ color: #ffffff;
216
+ font-weight: 600;
217
+ }
218
+
219
+ .bt-toolbar-divider {
220
+ width: 1px;
221
+ height: 16px;
222
+ background: #334155;
223
+ }
224
+
225
+ /* 4. Modal Backdrop & Editor */
226
+ .bt-modal-backdrop {
227
+ position: fixed;
228
+ top: 0;
229
+ left: 0;
230
+ width: 100vw;
231
+ height: 100vh;
232
+ background: rgba(15, 23, 42, 0.75);
233
+ backdrop-filter: blur(4px);
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ pointer-events: auto;
238
+ z-index: 2147483647;
239
+ opacity: 1;
240
+ box-sizing: border-box;
241
+ }
242
+
243
+ .bt-modal {
244
+ background: #1e293b;
245
+ color: #f8fafc;
246
+ border: 1px solid #334155;
247
+ border-radius: 12px;
248
+ padding: 18px;
249
+ width: 420px;
250
+ max-width: 90vw;
251
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.7);
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 12px;
255
+ box-sizing: border-box;
256
+ }
257
+
258
+ .bt-modal-header {
259
+ display: flex;
260
+ justify-content: space-between;
261
+ align-items: center;
262
+ }
263
+
264
+ .bt-modal-title {
265
+ font-size: 14px;
266
+ font-weight: 600;
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 6px;
270
+ color: #f8fafc;
271
+ }
272
+
273
+ .bt-mode-badge {
274
+ font-size: 10px;
275
+ font-weight: 700;
276
+ text-transform: uppercase;
277
+ background: rgba(59, 130, 246, 0.2);
278
+ color: #60a5fa;
279
+ border: 1px solid #3b82f6;
280
+ padding: 2px 6px;
281
+ border-radius: 4px;
282
+ }
283
+
284
+ .bt-close-btn {
285
+ background: transparent;
286
+ border: none;
287
+ color: #94a3b8;
288
+ font-size: 16px;
289
+ cursor: pointer;
290
+ padding: 2px 6px;
291
+ border-radius: 4px;
292
+ }
293
+ .bt-close-btn:hover {
294
+ background: #334155;
295
+ color: #ffffff;
296
+ }
297
+
298
+ .bt-target-pill {
299
+ background: #0f172a;
300
+ color: #94a3b8;
301
+ border: 1px solid #334155;
302
+ border-radius: 6px;
303
+ padding: 6px 10px;
304
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
305
+ font-size: 11px;
306
+ overflow: hidden;
307
+ text-overflow: ellipsis;
308
+ white-space: nowrap;
309
+ }
310
+
311
+ .bt-textarea {
312
+ background: #0f172a;
313
+ color: #f8fafc;
314
+ border: 1px solid #334155;
315
+ border-radius: 6px;
316
+ padding: 10px;
317
+ font-size: 13px;
318
+ resize: vertical;
319
+ min-height: 85px;
320
+ outline: none;
321
+ box-sizing: border-box;
322
+ width: 100%;
323
+ font-family: inherit;
324
+ }
325
+
326
+ .bt-textarea:focus {
327
+ border-color: #3b82f6;
328
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
329
+ }
330
+
331
+ .bt-modal-actions {
332
+ display: flex;
333
+ justify-content: flex-end;
334
+ gap: 8px;
335
+ }
336
+
337
+ .bt-btn {
338
+ padding: 8px 14px;
339
+ border-radius: 6px;
340
+ font-size: 12px;
341
+ font-weight: 600;
342
+ cursor: pointer;
343
+ border: 1px solid transparent;
344
+ transition: all 0.1s ease;
345
+ font-family: inherit;
346
+ }
347
+
348
+ .bt-btn-cancel {
349
+ background: transparent;
350
+ color: #94a3b8;
351
+ }
352
+ .bt-btn-cancel:hover {
353
+ background: #334155;
354
+ color: #f8fafc;
355
+ }
356
+
357
+ .bt-btn-save {
358
+ background: #2563eb;
359
+ color: #ffffff;
360
+ }
361
+ .bt-btn-save:hover {
362
+ background: #1d4ed8;
363
+ }
364
+ .bt-btn-save:disabled {
365
+ opacity: 0.6;
366
+ cursor: not-allowed;
367
+ }
368
+ `;
369
+
370
+ this.shadowRoot.appendChild(style);
371
+
372
+ // Element Highlight
373
+ this.highlightOverlay = document.createElement('div');
374
+ this.highlightOverlay.className = 'bt-highlight';
375
+ this.highlightOverlay.style.display = 'none';
376
+ this.shadowRoot.appendChild(this.highlightOverlay);
377
+
378
+ // Floating Toolbar
379
+ if (this.options.showToolbar) {
380
+ this.createToolbar();
381
+ }
382
+ }
383
+
384
+ if (this.container && !this.container.isConnected) {
385
+ const mountParent = document.body || document.documentElement;
386
+ if (mountParent) {
387
+ mountParent.appendChild(this.container);
388
+ }
389
+ }
390
+
391
+ return this.shadowRoot;
392
+ }
393
+
394
+ private createToolbar(): void {
395
+ if (!this.shadowRoot || this.toolbarElement) return;
396
+
397
+ this.toolbarElement = document.createElement('div');
398
+ this.toolbarElement.className = 'bt-toolbar';
399
+ this.toolbarElement.innerHTML = `
400
+ <button class="bt-toolbar-btn" id="bt-mode-element" title="Inspect element (or hold Alt+Click)">
401
+ <span>🎯</span> Element
402
+ </button>
403
+ <button class="bt-toolbar-btn" id="bt-mode-region" title="Drag to select screen region">
404
+ <span>📐</span> Region
405
+ </button>
406
+ <button class="bt-toolbar-btn" id="bt-mode-page" title="Leave full-page note">
407
+ <span>📄</span> Page
408
+ </button>
409
+ `;
410
+
411
+ const btnElement = this.toolbarElement.querySelector('#bt-mode-element') as HTMLButtonElement;
412
+ const btnRegion = this.toolbarElement.querySelector('#bt-mode-region') as HTMLButtonElement;
413
+ const btnPage = this.toolbarElement.querySelector('#bt-mode-page') as HTMLButtonElement;
414
+
415
+ btnElement.onclick = () => {
416
+ this.setMode(this.activeMode === 'element' ? 'idle' : 'element');
417
+ };
418
+
419
+ btnRegion.onclick = () => {
420
+ this.setMode(this.activeMode === 'region' ? 'idle' : 'region');
421
+ };
422
+
423
+ btnPage.onclick = () => {
424
+ this.setMode('page');
425
+ };
426
+
427
+ this.shadowRoot.appendChild(this.toolbarElement);
428
+ }
429
+
430
+ private updateToolbarState(): void {
431
+ if (!this.toolbarElement) return;
432
+
433
+ const btnElement = this.toolbarElement.querySelector('#bt-mode-element');
434
+ const btnRegion = this.toolbarElement.querySelector('#bt-mode-region');
435
+ const btnPage = this.toolbarElement.querySelector('#bt-mode-page');
436
+
437
+ btnElement?.classList.toggle('active', this.activeMode === 'element');
438
+ btnRegion?.classList.toggle('active', this.activeMode === 'region');
439
+ btnPage?.classList.toggle('active', this.activeMode === 'page');
440
+ }
441
+
442
+ private setupListeners(): void {
443
+ // 1. Mouse move: hover highlight in element mode or with Alt key
444
+ const onMouseMove = (e: MouseEvent) => {
445
+ if (this.modalOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
446
+
447
+ if (e.altKey || this.activeMode === 'element') {
448
+ const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
449
+ if (target && target !== this.container && !this.container?.contains(target)) {
450
+ this.hoveredElement = target;
451
+ this.updateHighlight(target);
452
+ return;
453
+ }
454
+ }
455
+
456
+ if (!e.altKey && this.activeMode !== 'element') {
457
+ this.hideHighlight();
458
+ }
459
+ };
460
+
461
+ // 2. Click: Alt+Click or Element mode click selects element and opens editor
462
+ const onClick = (e: MouseEvent) => {
463
+ if (this.modalOverlay || this.activeMode === 'region') return;
464
+
465
+ if (e.altKey || this.activeMode === 'element') {
466
+ e.preventDefault();
467
+ e.stopPropagation();
468
+
469
+ const target = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
470
+ if (target && target !== this.container && !this.container?.contains(target)) {
471
+ this.selectedElement = target;
472
+ this.openNoteEditor(target, 'element');
473
+ if (this.activeMode === 'element') {
474
+ this.setMode('idle');
475
+ }
476
+ }
477
+ }
478
+ };
479
+
480
+ // 3. Keyup for Alt release
481
+ const onKeyUp = (e: KeyboardEvent) => {
482
+ if (e.key === 'Alt' && !this.modalOverlay && this.activeMode !== 'element') {
483
+ this.hideHighlight();
484
+ }
485
+ };
486
+
487
+ window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
488
+ window.addEventListener('click', onClick, { capture: true });
489
+ window.addEventListener('keyup', onKeyUp, { capture: true });
490
+
491
+ this.cleanups.push(() => {
492
+ window.removeEventListener('mousemove', onMouseMove, { capture: true });
493
+ window.removeEventListener('click', onClick, { capture: true });
494
+ window.removeEventListener('keyup', onKeyUp, { capture: true });
495
+ });
496
+ }
497
+
498
+ private showRegionOverlay(): void {
499
+ const root = this.ensureContainer();
500
+ if (!root) return;
501
+
502
+ if (!this.regionOverlay) {
503
+ this.regionOverlay = document.createElement('div');
504
+ this.regionOverlay.className = 'bt-region-overlay';
505
+
506
+ this.regionBox = document.createElement('div');
507
+ this.regionBox.className = 'bt-region-box';
508
+ this.regionBox.style.display = 'none';
509
+ this.regionOverlay.appendChild(this.regionBox);
510
+
511
+ // Drag events
512
+ this.regionOverlay.onmousedown = (e: MouseEvent) => {
513
+ this.isDraggingRegion = true;
514
+ this.dragStartX = e.clientX;
515
+ this.dragStartY = e.clientY;
516
+ if (this.regionBox) {
517
+ this.regionBox.style.display = 'block';
518
+ this.regionBox.style.left = `${e.clientX}px`;
519
+ this.regionBox.style.top = `${e.clientY}px`;
520
+ this.regionBox.style.width = '0px';
521
+ this.regionBox.style.height = '0px';
522
+ }
523
+ };
524
+
525
+ this.regionOverlay.onmousemove = (e: MouseEvent) => {
526
+ if (!this.isDraggingRegion || !this.regionBox) return;
527
+
528
+ const currentX = e.clientX;
529
+ const currentY = e.clientY;
530
+
531
+ const left = Math.min(this.dragStartX, currentX);
532
+ const top = Math.min(this.dragStartY, currentY);
533
+ const width = Math.abs(currentX - this.dragStartX);
534
+ const height = Math.abs(currentY - this.dragStartY);
535
+
536
+ this.regionBox.style.left = `${left}px`;
537
+ this.regionBox.style.top = `${top}px`;
538
+ this.regionBox.style.width = `${width}px`;
539
+ this.regionBox.style.height = `${height}px`;
540
+
541
+ let badge = this.regionBox.querySelector('.bt-region-badge') as HTMLDivElement | null;
542
+ if (!badge) {
543
+ badge = document.createElement('div');
544
+ badge.className = 'bt-region-badge';
545
+ this.regionBox.appendChild(badge);
546
+ }
547
+ badge.textContent = `Region: ${Math.round(width)} × ${Math.round(height)} px`;
548
+ };
549
+
550
+ this.regionOverlay.onmouseup = (e: MouseEvent) => {
551
+ if (!this.isDraggingRegion) return;
552
+ this.isDraggingRegion = false;
553
+
554
+ const currentX = e.clientX;
555
+ const currentY = e.clientY;
556
+ const left = Math.min(this.dragStartX, currentX);
557
+ const top = Math.min(this.dragStartY, currentY);
558
+ const width = Math.abs(currentX - this.dragStartX);
559
+ const height = Math.abs(currentY - this.dragStartY);
560
+
561
+ if (width > 20 && height > 20) {
562
+ this.selectedRegion = {
563
+ x: Math.round(left),
564
+ y: Math.round(top),
565
+ width: Math.round(width),
566
+ height: Math.round(height),
567
+ };
568
+ this.hideRegionOverlay();
569
+ this.setMode('idle');
570
+ this.openRegionNoteEditor(this.selectedRegion);
571
+ } else {
572
+ this.hideRegionOverlay();
573
+ this.setMode('idle');
574
+ }
575
+ };
576
+ }
577
+
578
+ root.appendChild(this.regionOverlay);
579
+ }
580
+
581
+ private hideRegionOverlay(): void {
582
+ if (this.regionOverlay && this.regionOverlay.parentElement) {
583
+ this.regionOverlay.parentElement.removeChild(this.regionOverlay);
584
+ }
585
+ if (this.regionBox) {
586
+ this.regionBox.style.display = 'none';
587
+ }
588
+ }
589
+
590
+ private updateHighlight(el: HTMLElement): void {
591
+ const root = this.ensureContainer();
592
+ if (!root || !this.highlightOverlay) return;
593
+
594
+ const rect = el.getBoundingClientRect();
595
+ this.highlightOverlay.style.display = 'block';
596
+ this.highlightOverlay.style.top = `${rect.top}px`;
597
+ this.highlightOverlay.style.left = `${rect.left}px`;
598
+ this.highlightOverlay.style.width = `${rect.width}px`;
599
+ this.highlightOverlay.style.height = `${rect.height}px`;
600
+
601
+ const selector = getSemanticSelector(el);
602
+ const badgeText = `${selector} · ${Math.round(rect.width)}×${Math.round(rect.height)}`;
603
+
604
+ let badge = this.highlightOverlay.querySelector('.bt-badge') as HTMLDivElement | null;
605
+ if (!badge) {
606
+ badge = document.createElement('div');
607
+ badge.className = 'bt-badge';
608
+ this.highlightOverlay.appendChild(badge);
609
+ }
610
+ badge.textContent = badgeText;
611
+ }
612
+
613
+ private hideHighlight(): void {
614
+ if (this.highlightOverlay) {
615
+ this.highlightOverlay.style.display = 'none';
616
+ }
617
+ }
618
+
619
+ public openNoteEditor(el?: HTMLElement, noteType: 'element' | 'page' = 'element'): void {
620
+ const root = this.ensureContainer();
621
+ if (!root) return;
622
+
623
+ const targetEl = el || document.body;
624
+ this.selectedElement = targetEl;
625
+ if (noteType === 'element') {
626
+ this.updateHighlight(targetEl);
627
+ }
628
+
629
+ this.renderNoteModal({
630
+ title: '📝 Add Visual Note',
631
+ modeBadge: noteType.toUpperCase(),
632
+ pillText:
633
+ noteType === 'page'
634
+ ? `Page Viewport: ${window.innerWidth} × ${window.innerHeight} px`
635
+ : `${getSemanticSelector(targetEl)} (${Math.round(targetEl.getBoundingClientRect().width)}×${Math.round(targetEl.getBoundingClientRect().height)}) · Viewport: ${window.innerWidth}×${window.innerHeight}`,
636
+ onSave: async (message) => {
637
+ await this.saveVisualNote(targetEl, message, noteType);
638
+ },
639
+ });
640
+ }
641
+
642
+ public openRegionNoteEditor(region: RegionContext): void {
643
+ this.renderNoteModal({
644
+ title: '📝 Add Region Note',
645
+ modeBadge: 'REGION',
646
+ pillText: `Selected Area: x:${region.x}, y:${region.y} (${region.width} × ${region.height} px)`,
647
+ onSave: async (message) => {
648
+ await this.saveRegionVisualNote(region, message);
649
+ },
650
+ });
651
+ }
652
+
653
+ private renderNoteModal(options: {
654
+ title: string;
655
+ modeBadge: string;
656
+ pillText: string;
657
+ onSave: (message: string) => Promise<void>;
658
+ }): void {
659
+ const root = this.ensureContainer();
660
+ if (!root) return;
661
+
662
+ // Remove existing modal if any
663
+ if (this.modalOverlay && this.modalOverlay.parentElement) {
664
+ this.modalOverlay.parentElement.removeChild(this.modalOverlay);
665
+ this.modalOverlay = null;
666
+ }
667
+
668
+ const backdrop = document.createElement('div');
669
+ backdrop.className = 'bt-modal-backdrop';
670
+ this.modalOverlay = backdrop;
671
+
672
+ backdrop.innerHTML = `
673
+ <div class="bt-modal">
674
+ <div class="bt-modal-header">
675
+ <div class="bt-modal-title">
676
+ <span>${options.title}</span>
677
+ <span class="bt-mode-badge">${options.modeBadge}</span>
678
+ </div>
679
+ <button class="bt-close-btn" id="btn-close" title="Close">✕</button>
680
+ </div>
681
+ <div class="bt-target-pill" title="${options.pillText}">
682
+ 🎯 ${options.pillText}
683
+ </div>
684
+ <textarea class="bt-textarea" placeholder="Describe the layout issue, styling bug, or note for AI agent..." autofocus></textarea>
685
+ <div class="bt-modal-actions">
686
+ <button class="bt-btn bt-btn-cancel" id="btn-cancel">Cancel</button>
687
+ <button class="bt-btn bt-btn-save" id="btn-save">Save Note</button>
688
+ </div>
689
+ </div>
690
+ `;
691
+
692
+ const textarea = backdrop.querySelector('textarea') as HTMLTextAreaElement;
693
+ const btnClose = backdrop.querySelector('#btn-close') as HTMLButtonElement;
694
+ const btnCancel = backdrop.querySelector('#btn-cancel') as HTMLButtonElement;
695
+ const btnSave = backdrop.querySelector('#btn-save') as HTMLButtonElement;
696
+
697
+ const closeModal = () => {
698
+ if (this.modalOverlay) {
699
+ root.removeChild(this.modalOverlay);
700
+ this.modalOverlay = null;
701
+ this.hideHighlight();
702
+ this.hideRegionOverlay();
703
+ }
704
+ };
705
+
706
+ btnClose.onclick = closeModal;
707
+ btnCancel.onclick = closeModal;
708
+
709
+ backdrop.onclick = (e: MouseEvent) => {
710
+ if (e.target === backdrop) {
711
+ closeModal();
712
+ }
713
+ };
714
+
715
+ const submitNote = async () => {
716
+ const message = textarea.value.trim();
717
+ if (!message) return;
718
+
719
+ btnSave.textContent = 'Saving...';
720
+ btnSave.disabled = true;
721
+
722
+ try {
723
+ await options.onSave(message);
724
+ } finally {
725
+ closeModal();
726
+ }
727
+ };
728
+
729
+ btnSave.onclick = submitNote;
730
+
731
+ textarea.onkeydown = (e: KeyboardEvent) => {
732
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
733
+ e.preventDefault();
734
+ submitNote();
735
+ }
736
+ if (e.key === 'Escape') {
737
+ closeModal();
738
+ }
739
+ };
740
+
741
+ root.appendChild(backdrop);
742
+ setTimeout(() => textarea?.focus(), 50);
743
+ }
744
+
745
+ public async saveVisualNote(targetEl: HTMLElement, message: string, noteType: 'element' | 'page' = 'element'): Promise<void> {
746
+ const rect = targetEl.getBoundingClientRect();
747
+ const selector = noteType === 'page' ? 'body' : getSemanticSelector(targetEl);
748
+
749
+ // 1. Capture screenshot via screenshotDriver
750
+ let screenshotDataUrl: string | undefined;
751
+ try {
752
+ const snap = await this.screenshotDriver.captureElement(targetEl);
753
+ if (snap.ok) {
754
+ screenshotDataUrl = snap.dataUrl;
755
+ }
756
+ } catch {
757
+ // Defensive
758
+ }
759
+
760
+ // 2. Extract DOM Context
761
+ const elementContext = this.extractElementContext(targetEl);
762
+
763
+ // 3. Extract Target
764
+ const target: NoteTarget = {
765
+ selector,
766
+ boundingRect: {
767
+ x: Math.round(rect.x),
768
+ y: Math.round(rect.y),
769
+ width: Math.round(rect.width),
770
+ height: Math.round(rect.height),
771
+ top: Math.round(rect.top),
772
+ left: Math.round(rect.left),
773
+ bottom: Math.round(rect.bottom),
774
+ right: Math.round(rect.right),
775
+ },
776
+ visible: rect.width > 0 && rect.height > 0,
777
+ confidence: selector.startsWith('[data-test') ? 'high' : selector.startsWith('#') ? 'medium' : 'low',
778
+ };
779
+
780
+ const notePayload = {
781
+ type: 'create_note',
782
+ sessionId: this.transport.getSessionId() || '',
783
+ noteType,
784
+ message,
785
+ route: window.location.pathname + window.location.search,
786
+ url: window.location.href,
787
+ viewport: {
788
+ width: window.innerWidth,
789
+ height: window.innerHeight,
790
+ devicePixelRatio: window.devicePixelRatio || 1,
791
+ },
792
+ scroll: {
793
+ scrollX: window.scrollX,
794
+ scrollY: window.scrollY,
795
+ },
796
+ target,
797
+ elementContext,
798
+ screenshot: screenshotDataUrl,
799
+ timestamp: Date.now(),
800
+ };
801
+
802
+ this.transport.send(notePayload);
803
+ if (this.options.onNoteCreated) {
804
+ this.options.onNoteCreated(notePayload as any);
805
+ }
806
+ }
807
+
808
+ public async saveRegionVisualNote(region: RegionContext, message: string): Promise<void> {
809
+ // 1. Capture full page screenshot and crop to region
810
+ let screenshotDataUrl: string | undefined;
811
+ try {
812
+ const snap = await this.screenshotDriver.captureElement(document.body || document.documentElement);
813
+ if (snap.ok && snap.dataUrl) {
814
+ screenshotDataUrl = await this.cropDataUrl(snap.dataUrl, region);
815
+ }
816
+ } catch {
817
+ // Defensive
818
+ }
819
+
820
+ const notePayload = {
821
+ type: 'create_note',
822
+ sessionId: this.transport.getSessionId() || '',
823
+ noteType: 'region',
824
+ message,
825
+ route: window.location.pathname + window.location.search,
826
+ url: window.location.href,
827
+ viewport: {
828
+ width: window.innerWidth,
829
+ height: window.innerHeight,
830
+ devicePixelRatio: window.devicePixelRatio || 1,
831
+ },
832
+ scroll: {
833
+ scrollX: window.scrollX,
834
+ scrollY: window.scrollY,
835
+ },
836
+ region,
837
+ screenshot: screenshotDataUrl,
838
+ timestamp: Date.now(),
839
+ };
840
+
841
+ this.transport.send(notePayload);
842
+ if (this.options.onNoteCreated) {
843
+ this.options.onNoteCreated(notePayload as any);
844
+ }
845
+ }
846
+
847
+ private async cropDataUrl(dataUrl: string, region: RegionContext): Promise<string> {
848
+ return new Promise((resolve) => {
849
+ const img = new Image();
850
+ img.onload = () => {
851
+ try {
852
+ const canvas = document.createElement('canvas');
853
+ canvas.width = region.width;
854
+ canvas.height = region.height;
855
+ const ctx = canvas.getContext('2d');
856
+ if (!ctx) {
857
+ resolve(dataUrl);
858
+ return;
859
+ }
860
+
861
+ const dpr = window.devicePixelRatio || 1;
862
+ ctx.drawImage(
863
+ img,
864
+ region.x * dpr,
865
+ region.y * dpr,
866
+ region.width * dpr,
867
+ region.height * dpr,
868
+ 0,
869
+ 0,
870
+ region.width,
871
+ region.height
872
+ );
873
+ resolve(canvas.toDataURL('image/webp', 0.9));
874
+ } catch {
875
+ resolve(dataUrl);
876
+ }
877
+ };
878
+ img.onerror = () => resolve(dataUrl);
879
+ img.src = dataUrl;
880
+ });
881
+ }
882
+
883
+ private extractElementContext(el: HTMLElement): ElementContext {
884
+ const selector = getSemanticSelector(el);
885
+ const tag = el.tagName.toLowerCase();
886
+
887
+ // Attributes (sanitize passwords/tokens)
888
+ const attributes: Record<string, string> = {};
889
+ for (let i = 0; i < el.attributes.length; i++) {
890
+ const attr = el.attributes[i];
891
+ if (attr.name === 'value' && (el as HTMLInputElement).type === 'password') {
892
+ attributes[attr.name] = '[REDACTED]';
893
+ } else {
894
+ attributes[attr.name] = attr.value;
895
+ }
896
+ }
897
+
898
+ // Clone element to sanitize inner sensitive fields
899
+ let outerHTML = '';
900
+ try {
901
+ const clone = el.cloneNode(true) as HTMLElement;
902
+ for (const passInput of Array.from(clone.querySelectorAll('input[type="password"]'))) {
903
+ passInput.setAttribute('value', '[REDACTED]');
904
+ }
905
+ outerHTML = truncate(clone.outerHTML, 10240); // 10 KB max
906
+ } catch {
907
+ outerHTML = truncate(el.outerHTML, 10240);
908
+ }
909
+
910
+ let parent: { selector: string; tag: string } | undefined;
911
+ if (el.parentElement && el.parentElement !== document.body) {
912
+ parent = {
913
+ selector: getSemanticSelector(el.parentElement),
914
+ tag: el.parentElement.tagName.toLowerCase(),
915
+ };
916
+ }
917
+
918
+ return {
919
+ selector,
920
+ tag,
921
+ attributes,
922
+ outerHTML,
923
+ innerText: truncate(el.textContent?.trim(), 200),
924
+ parent,
925
+ };
926
+ }
927
+
928
+ public destroy(): void {
929
+ for (const cleanup of this.cleanups) {
930
+ try {
931
+ cleanup();
932
+ } catch {}
933
+ }
934
+ this.cleanups = [];
935
+
936
+ if (this.container && this.container.parentElement) {
937
+ this.container.parentElement.removeChild(this.container);
938
+ }
939
+ this.container = null;
940
+ this.shadowRoot = null;
941
+ this.toolbarElement = null;
942
+ }
943
+ }