browsertrack 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.github/workflows/docs.yml +54 -0
  2. package/AGENTS.md +133 -0
  3. package/README.md +143 -0
  4. package/dist/chunk-6VA7GBAO.js +246 -0
  5. package/dist/chunk-6VA7GBAO.js.map +1 -0
  6. package/dist/chunk-G2Y3CXCY.js +659 -0
  7. package/dist/chunk-G2Y3CXCY.js.map +1 -0
  8. package/dist/chunk-ILRYKMME.js +1228 -0
  9. package/dist/chunk-ILRYKMME.js.map +1 -0
  10. package/dist/chunk-SKCMT2DE.js +2641 -0
  11. package/dist/chunk-SKCMT2DE.js.map +1 -0
  12. package/dist/chunk-SPCIROIU.js +616 -0
  13. package/dist/chunk-SPCIROIU.js.map +1 -0
  14. package/dist/cli/index.js +2942 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/client/index.cjs +2780 -0
  17. package/dist/client/index.d.ts +211 -0
  18. package/dist/client/index.js +20 -0
  19. package/dist/client/index.js.map +1 -0
  20. package/dist/client.iife.js +630 -0
  21. package/dist/core/index.d.ts +86 -0
  22. package/dist/core/index.js +37 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/daemon/index.d.ts +58 -0
  25. package/dist/daemon/index.js +32 -0
  26. package/dist/daemon/index.js.map +1 -0
  27. package/dist/engine-CeT9URuN.d.ts +161 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +56 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/mcp/index.d.ts +11 -0
  32. package/dist/mcp/index.js +12 -0
  33. package/dist/mcp/index.js.map +1 -0
  34. package/dist/notes-CBvN91Wf.d.ts +260 -0
  35. package/dist/projects-CY8ungMt.d.ts +99 -0
  36. package/dist/server-Dd8NX2Mk.d.ts +55 -0
  37. package/docboot.config.js +16 -0
  38. package/docs/cli.md +51 -0
  39. package/docs/closed-loop-verification.md +57 -0
  40. package/docs/getting-started.md +122 -0
  41. package/docs/incidents-diagnostics.md +61 -0
  42. package/docs/index.md +46 -0
  43. package/docs/mcp-reference.md +128 -0
  44. package/docs/security-privacy.md +32 -0
  45. package/docs/visual-notes.md +67 -0
  46. package/examples/test-app/index.html +299 -0
  47. package/package.json +56 -7
  48. package/packages/cli/src/index.ts +413 -0
  49. package/packages/client/package.json +28 -0
  50. package/packages/client/src/breadcrumbs.ts +30 -0
  51. package/packages/client/src/client.ts +278 -0
  52. package/packages/client/src/commands/handler.ts +301 -0
  53. package/packages/client/src/config.ts +37 -0
  54. package/packages/client/src/index.ts +50 -0
  55. package/packages/client/src/interceptors/console.ts +68 -0
  56. package/packages/client/src/interceptors/interaction.ts +119 -0
  57. package/packages/client/src/interceptors/navigation.ts +93 -0
  58. package/packages/client/src/interceptors/network.ts +165 -0
  59. package/packages/client/src/interceptors/runtime.ts +65 -0
  60. package/packages/client/src/notes/inspector.ts +1553 -0
  61. package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
  62. package/packages/client/src/screenshot/driver.ts +14 -0
  63. package/packages/client/src/transport/websocket.ts +194 -0
  64. package/packages/client/tsconfig.json +8 -0
  65. package/packages/core/package.json +26 -0
  66. package/packages/core/src/fingerprint.ts +120 -0
  67. package/packages/core/src/index.ts +9 -0
  68. package/packages/core/src/redaction.ts +174 -0
  69. package/packages/core/src/selector.ts +77 -0
  70. package/packages/core/src/types/commands.ts +101 -0
  71. package/packages/core/src/types/events.ts +108 -0
  72. package/packages/core/src/types/incidents.ts +54 -0
  73. package/packages/core/src/types/notes.ts +106 -0
  74. package/packages/core/src/types/probes.ts +44 -0
  75. package/packages/core/src/types/projects.ts +20 -0
  76. package/packages/core/tsconfig.json +8 -0
  77. package/packages/daemon/src/config.ts +29 -0
  78. package/packages/daemon/src/incidents/engine.ts +211 -0
  79. package/packages/daemon/src/index.ts +18 -0
  80. package/packages/daemon/src/notes/engine.ts +92 -0
  81. package/packages/daemon/src/notes/verification.ts +191 -0
  82. package/packages/daemon/src/server/daemon.ts +112 -0
  83. package/packages/daemon/src/server/http.ts +165 -0
  84. package/packages/daemon/src/server/ws.ts +246 -0
  85. package/packages/daemon/src/session/manager.ts +147 -0
  86. package/packages/daemon/src/storage/db.ts +742 -0
  87. package/packages/daemon/src/storage/screenshot-store.ts +49 -0
  88. package/packages/daemon/src/verification/engine.ts +264 -0
  89. package/packages/mcp/src/handlers.ts +398 -0
  90. package/packages/mcp/src/index.ts +3 -0
  91. package/packages/mcp/src/server.ts +86 -0
  92. package/packages/mcp/src/tools.ts +236 -0
  93. package/src/index.ts +4 -0
  94. package/test/client/interceptors.test.ts +130 -0
  95. package/test/core/fingerprint.test.ts +53 -0
  96. package/test/core/notes.test.ts +66 -0
  97. package/test/core/redaction.test.ts +60 -0
  98. package/test/daemon/incident-engine.test.ts +98 -0
  99. package/test/daemon/notes-storage.test.ts +130 -0
  100. package/test/daemon/notes-verification.test.ts +135 -0
  101. package/test/daemon/storage.test.ts +123 -0
  102. package/test/daemon/verification-engine.test.ts +88 -0
  103. package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
  104. package/test/e2e/visual-notes-e2e.test.ts +205 -0
  105. package/test/mcp/handlers.test.ts +116 -0
  106. package/test/mcp/notes.test.ts +107 -0
  107. package/tsconfig.base.json +17 -0
  108. package/tsconfig.json +26 -0
  109. package/tsup.config.ts +54 -0
  110. package/vitest.config.ts +9 -0
@@ -0,0 +1,1553 @@
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 with cancel banner)
20
+ * 3. Whole page note
21
+ * 4. Real-time Saved Note Markers (pins on elements/regions when page loads or syncs)
22
+ * 5. Interactive Note Detail Card (view message, target, resolve/delete actions)
23
+ * 6. Floating quick dock / toolbar in bottom-right corner with Notes count toggle
24
+ */
25
+ export class NoteInspector {
26
+ private transport: WebSocketTransport;
27
+ private screenshotDriver: ScreenshotDriver;
28
+ private options: InspectorOptions;
29
+
30
+ private container: HTMLDivElement | null = null;
31
+ private shadowRoot: ShadowRoot | null = null;
32
+ private highlightOverlay: HTMLDivElement | null = null;
33
+ private regionOverlay: HTMLDivElement | null = null;
34
+ private regionBox: HTMLDivElement | null = null;
35
+ private regionBanner: HTMLDivElement | null = null;
36
+ private markersContainer: HTMLDivElement | null = null;
37
+ private toolbarElement: HTMLDivElement | null = null;
38
+ private modalOverlay: HTMLDivElement | null = null;
39
+ private cardOverlay: HTMLDivElement | null = null;
40
+
41
+ private activeMode: NoteInspectMode = 'idle';
42
+ private hoveredElement: HTMLElement | null = null;
43
+ private selectedElement: HTMLElement | null = null;
44
+ private selectedRegion: RegionContext | null = null;
45
+
46
+ private isDraggingRegion = false;
47
+ private dragStartX = 0;
48
+ private dragStartY = 0;
49
+
50
+ private savedNotes: VisualNote[] = [];
51
+ private showMarkers = true;
52
+ private cleanups: (() => void)[] = [];
53
+
54
+ constructor(transport: WebSocketTransport, screenshotDriver: ScreenshotDriver, options: InspectorOptions = {}) {
55
+ this.transport = transport;
56
+ this.screenshotDriver = screenshotDriver;
57
+ this.options = {
58
+ shortcut: 'Alt+Click',
59
+ maskSelectors: ['input[type="password"]', '[data-sensitive]'],
60
+ showToolbar: true,
61
+ ...options,
62
+ };
63
+ }
64
+
65
+ public init(): void {
66
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
67
+
68
+ if (document.readyState === 'loading') {
69
+ document.addEventListener('DOMContentLoaded', () => {
70
+ this.ensureContainer();
71
+ });
72
+ } else {
73
+ this.ensureContainer();
74
+ }
75
+
76
+ // Subscribe to incoming notes synchronization from daemon
77
+ const unsubscribe = this.transport.onMessage((msg) => {
78
+ if (msg && msg.type === 'notes_sync' && Array.isArray(msg.notes)) {
79
+ this.syncNotes(msg.notes);
80
+ }
81
+ });
82
+ this.cleanups.push(unsubscribe);
83
+
84
+ this.setupListeners();
85
+ }
86
+
87
+ public syncNotes(notes: VisualNote[]): void {
88
+ this.savedNotes = notes;
89
+ this.updateToolbarCount();
90
+ this.renderMarkers();
91
+ }
92
+
93
+ public setMode(mode: NoteInspectMode): void {
94
+ this.activeMode = mode;
95
+ this.updateToolbarState();
96
+
97
+ if (mode === 'element') {
98
+ this.hideRegionOverlay();
99
+ } else if (mode === 'region') {
100
+ this.hideHighlight();
101
+ this.showRegionOverlay();
102
+ } else if (mode === 'page') {
103
+ this.hideRegionOverlay();
104
+ this.hideHighlight();
105
+ this.openNoteEditor(document.body, 'page');
106
+ } else {
107
+ this.hideRegionOverlay();
108
+ this.hideHighlight();
109
+ }
110
+ }
111
+
112
+ private ensureContainer(): ShadowRoot | null {
113
+ if (typeof document === 'undefined') return null;
114
+
115
+ if (!this.container) {
116
+ this.container = document.createElement('div');
117
+ this.container.id = 'browsertrack-inspector-host';
118
+ this.container.style.cssText =
119
+ 'all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;';
120
+
121
+ this.shadowRoot = this.container.attachShadow({ mode: 'open' });
122
+
123
+ // Styles for highlight overlay, region drag box, markers, floating toolbar and modals
124
+ const style = document.createElement('style');
125
+ style.textContent = `
126
+ :host {
127
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
128
+ color-scheme: dark;
129
+ -webkit-font-smoothing: antialiased;
130
+ -moz-osx-font-smoothing: grayscale;
131
+ }
132
+
133
+ /* 1. Element Highlight Overlay */
134
+ .bt-highlight {
135
+ position: fixed;
136
+ border: 2px solid #3b82f6;
137
+ background: rgba(59, 130, 246, 0.16);
138
+ border-radius: 4px;
139
+ pointer-events: none;
140
+ transition: all 0.05s ease-out;
141
+ z-index: 2147483640;
142
+ box-sizing: border-box;
143
+ }
144
+
145
+ .bt-badge {
146
+ position: absolute;
147
+ top: -26px;
148
+ left: 0;
149
+ background: #0f172a;
150
+ color: #38bdf8;
151
+ border: 1px solid #3b82f6;
152
+ font-size: 11px;
153
+ font-weight: 600;
154
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
155
+ padding: 2px 7px;
156
+ border-radius: 4px;
157
+ white-space: nowrap;
158
+ pointer-events: none;
159
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.5);
160
+ }
161
+
162
+ /* 2. Region Selection Overlay */
163
+ .bt-region-overlay {
164
+ position: fixed;
165
+ inset: 0;
166
+ width: 100vw;
167
+ height: 100vh;
168
+ cursor: crosshair;
169
+ z-index: 2147483642;
170
+ pointer-events: auto;
171
+ background: rgba(15, 23, 42, 0.25);
172
+ user-select: none;
173
+ }
174
+
175
+ .bt-region-box {
176
+ position: fixed;
177
+ border: 2px dashed #38bdf8;
178
+ background: rgba(56, 189, 248, 0.2);
179
+ box-sizing: border-box;
180
+ pointer-events: none;
181
+ z-index: 2147483643;
182
+ }
183
+
184
+ .bt-region-badge {
185
+ position: absolute;
186
+ top: -26px;
187
+ left: 0;
188
+ background: #0f172a;
189
+ color: #38bdf8;
190
+ border: 1px solid #0284c7;
191
+ font-size: 11px;
192
+ font-weight: 600;
193
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
194
+ padding: 2px 7px;
195
+ border-radius: 4px;
196
+ white-space: nowrap;
197
+ pointer-events: none;
198
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.5);
199
+ }
200
+
201
+ .bt-region-banner {
202
+ position: fixed;
203
+ top: 20px;
204
+ left: 50%;
205
+ transform: translateX(-50%);
206
+ background: #0f172a;
207
+ color: #f8fafc;
208
+ border: 1px solid #0284c7;
209
+ border-radius: 30px;
210
+ padding: 8px 16px;
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 12px;
214
+ box-shadow: 0 10px 25px -5px rgba(0,0,0,0.6);
215
+ font-size: 12.5px;
216
+ font-weight: 500;
217
+ pointer-events: auto;
218
+ z-index: 2147483645;
219
+ user-select: none;
220
+ }
221
+
222
+ .bt-region-cancel-btn {
223
+ background: rgba(239, 68, 68, 0.15);
224
+ color: #fca5a5;
225
+ border: 1px solid rgba(239, 68, 68, 0.4);
226
+ padding: 3px 10px;
227
+ border-radius: 20px;
228
+ font-size: 11px;
229
+ font-weight: 600;
230
+ cursor: pointer;
231
+ transition: all 0.15s ease;
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 4px;
235
+ font-family: inherit;
236
+ }
237
+
238
+ .bt-region-cancel-btn:hover {
239
+ background: rgba(239, 68, 68, 0.3);
240
+ color: #ffffff;
241
+ }
242
+
243
+ /* 3. Note Markers on Page */
244
+ .bt-markers-layer {
245
+ position: fixed;
246
+ top: 0;
247
+ left: 0;
248
+ width: 0;
249
+ height: 0;
250
+ pointer-events: none;
251
+ z-index: 2147483638;
252
+ }
253
+
254
+ .bt-note-marker {
255
+ position: fixed;
256
+ pointer-events: auto;
257
+ background: linear-gradient(135deg, #2563eb, #1d4ed8);
258
+ color: #ffffff;
259
+ border: 2px solid #ffffff;
260
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5), 0 0 0 1px rgba(37,99,235,0.4);
261
+ border-radius: 999px;
262
+ height: 26px;
263
+ min-width: 26px;
264
+ padding: 0 7px;
265
+ display: inline-flex;
266
+ align-items: center;
267
+ justify-content: center;
268
+ gap: 4px;
269
+ cursor: pointer;
270
+ font-size: 11px;
271
+ font-weight: 700;
272
+ user-select: none;
273
+ transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.15s ease;
274
+ animation: bt-pop-in 0.2s ease-out;
275
+ box-sizing: border-box;
276
+ z-index: 2147483641;
277
+ }
278
+
279
+ @keyframes bt-pop-in {
280
+ 0% { transform: scale(0.6); opacity: 0; }
281
+ 100% { transform: scale(1); opacity: 1; }
282
+ }
283
+
284
+ .bt-note-marker:hover {
285
+ transform: scale(1.15) translateY(-2px);
286
+ box-shadow: 0 8px 20px rgba(37,99,235,0.6), 0 0 0 2px #60a5fa;
287
+ }
288
+
289
+ .bt-marker-resolved {
290
+ background: linear-gradient(135deg, #475569, #334155);
291
+ border-color: #94a3b8;
292
+ opacity: 0.75;
293
+ }
294
+
295
+ .bt-region-marker-box {
296
+ position: fixed;
297
+ border: 2px dashed #0284c7;
298
+ background: rgba(14, 165, 233, 0.08);
299
+ pointer-events: none;
300
+ box-sizing: border-box;
301
+ border-radius: 6px;
302
+ z-index: 2147483639;
303
+ }
304
+
305
+ .bt-page-notes-dock {
306
+ position: fixed;
307
+ top: 16px;
308
+ right: 16px;
309
+ display: flex;
310
+ flex-direction: column;
311
+ gap: 6px;
312
+ pointer-events: none;
313
+ z-index: 2147483641;
314
+ }
315
+
316
+ .bt-page-note-pill {
317
+ background: #0f172a;
318
+ color: #c084fc;
319
+ border: 1px solid #7e22ce;
320
+ border-radius: 20px;
321
+ padding: 5px 12px;
322
+ font-size: 11.5px;
323
+ font-weight: 600;
324
+ display: inline-flex;
325
+ align-items: center;
326
+ gap: 6px;
327
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
328
+ cursor: pointer;
329
+ pointer-events: auto;
330
+ transition: all 0.15s ease;
331
+ }
332
+
333
+ .bt-page-note-pill:hover {
334
+ background: #1e1b4b;
335
+ transform: translateY(-1px);
336
+ }
337
+
338
+ /* 4. Floating Quick Toolbar */
339
+ .bt-toolbar {
340
+ position: fixed;
341
+ bottom: 20px;
342
+ right: 20px;
343
+ background: #0f172a;
344
+ border: 1px solid #334155;
345
+ border-radius: 30px;
346
+ padding: 4px 6px;
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 4px;
350
+ box-shadow: 0 10px 25px -3px rgba(0,0,0,0.6), 0 4px 6px -4px rgba(0,0,0,0.4);
351
+ pointer-events: auto;
352
+ z-index: 2147483646;
353
+ user-select: none;
354
+ transition: all 0.2s ease;
355
+ }
356
+
357
+ .bt-toolbar-btn {
358
+ background: transparent;
359
+ border: none;
360
+ color: #94a3b8;
361
+ font-size: 12px;
362
+ font-weight: 500;
363
+ padding: 6px 12px;
364
+ border-radius: 20px;
365
+ cursor: pointer;
366
+ display: flex;
367
+ align-items: center;
368
+ gap: 6px;
369
+ transition: all 0.15s ease;
370
+ font-family: inherit;
371
+ }
372
+
373
+ .bt-toolbar-btn:hover {
374
+ background: #1e293b;
375
+ color: #f8fafc;
376
+ }
377
+
378
+ .bt-toolbar-btn.active {
379
+ background: #2563eb;
380
+ color: #ffffff;
381
+ font-weight: 600;
382
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.35);
383
+ }
384
+
385
+ .bt-count-pill {
386
+ background: rgba(255, 255, 255, 0.2);
387
+ color: #ffffff;
388
+ font-size: 10px;
389
+ font-weight: 700;
390
+ padding: 1px 6px;
391
+ border-radius: 10px;
392
+ line-height: 1.2;
393
+ }
394
+
395
+ .bt-toolbar-divider {
396
+ width: 1px;
397
+ height: 16px;
398
+ background: #334155;
399
+ margin: 0 2px;
400
+ }
401
+
402
+ /* 5. Modals & Popover Card */
403
+ .bt-modal-backdrop {
404
+ position: fixed;
405
+ top: 0;
406
+ left: 0;
407
+ width: 100vw;
408
+ height: 100vh;
409
+ background: rgba(15, 23, 42, 0.75);
410
+ backdrop-filter: blur(6px);
411
+ display: flex;
412
+ align-items: center;
413
+ justify-content: center;
414
+ pointer-events: auto;
415
+ z-index: 2147483647;
416
+ opacity: 1;
417
+ box-sizing: border-box;
418
+ animation: bt-fade-in 0.15s ease-out;
419
+ }
420
+
421
+ @keyframes bt-fade-in {
422
+ from { opacity: 0; transform: scale(0.98); }
423
+ to { opacity: 1; transform: scale(1); }
424
+ }
425
+
426
+ .bt-modal {
427
+ background: #0f172a;
428
+ color: #f8fafc;
429
+ border: 1px solid #334155;
430
+ border-radius: 14px;
431
+ padding: 20px;
432
+ width: 450px;
433
+ max-width: 92vw;
434
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05);
435
+ display: flex;
436
+ flex-direction: column;
437
+ gap: 14px;
438
+ box-sizing: border-box;
439
+ }
440
+
441
+ .bt-modal-header {
442
+ display: flex;
443
+ justify-content: space-between;
444
+ align-items: center;
445
+ }
446
+
447
+ .bt-modal-title {
448
+ font-size: 14.5px;
449
+ font-weight: 600;
450
+ letter-spacing: -0.01em;
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 8px;
454
+ color: #f8fafc;
455
+ }
456
+
457
+ .bt-mode-badge {
458
+ font-size: 10px;
459
+ font-weight: 700;
460
+ letter-spacing: 0.05em;
461
+ text-transform: uppercase;
462
+ padding: 2px 7px;
463
+ border-radius: 6px;
464
+ }
465
+
466
+ .bt-badge-element {
467
+ background: rgba(59, 130, 246, 0.15);
468
+ color: #60a5fa;
469
+ border: 1px solid rgba(59, 130, 246, 0.4);
470
+ }
471
+
472
+ .bt-badge-region {
473
+ background: rgba(14, 165, 233, 0.15);
474
+ color: #38bdf8;
475
+ border: 1px solid rgba(14, 165, 233, 0.4);
476
+ }
477
+
478
+ .bt-badge-page {
479
+ background: rgba(168, 85, 247, 0.15);
480
+ color: #c084fc;
481
+ border: 1px solid rgba(168, 85, 247, 0.4);
482
+ }
483
+
484
+ .bt-badge-status-open {
485
+ background: rgba(16, 185, 129, 0.15);
486
+ color: #34d399;
487
+ border: 1px solid rgba(16, 185, 129, 0.4);
488
+ }
489
+
490
+ .bt-badge-status-resolved {
491
+ background: rgba(100, 116, 139, 0.2);
492
+ color: #94a3b8;
493
+ border: 1px solid rgba(100, 116, 139, 0.4);
494
+ }
495
+
496
+ .bt-close-btn {
497
+ background: transparent;
498
+ border: none;
499
+ color: #94a3b8;
500
+ font-size: 15px;
501
+ cursor: pointer;
502
+ padding: 3px 7px;
503
+ border-radius: 6px;
504
+ transition: all 0.12s ease;
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ }
509
+ .bt-close-btn:hover {
510
+ background: #1e293b;
511
+ color: #ffffff;
512
+ }
513
+
514
+ .bt-target-pill {
515
+ background: #090d16;
516
+ color: #94a3b8;
517
+ border: 1px solid #1e293b;
518
+ border-radius: 8px;
519
+ padding: 8px 12px;
520
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
521
+ font-size: 11.5px;
522
+ display: flex;
523
+ align-items: center;
524
+ gap: 8px;
525
+ overflow: hidden;
526
+ }
527
+
528
+ .bt-pill-content {
529
+ overflow: hidden;
530
+ text-overflow: ellipsis;
531
+ white-space: nowrap;
532
+ color: #cbd5e1;
533
+ }
534
+
535
+ .bt-note-message-box {
536
+ background: #090d16;
537
+ border: 1px solid #1e293b;
538
+ border-radius: 8px;
539
+ padding: 12px 14px;
540
+ font-size: 13.5px;
541
+ line-height: 1.5;
542
+ color: #f1f5f9;
543
+ white-space: pre-wrap;
544
+ word-break: break-word;
545
+ max-height: 180px;
546
+ overflow-y: auto;
547
+ }
548
+
549
+ .bt-textarea {
550
+ background: #090d16;
551
+ color: #f8fafc;
552
+ border: 1px solid #334155;
553
+ border-radius: 8px;
554
+ padding: 12px;
555
+ font-size: 13.5px;
556
+ line-height: 1.5;
557
+ resize: vertical;
558
+ min-height: 95px;
559
+ outline: none;
560
+ box-sizing: border-box;
561
+ width: 100%;
562
+ font-family: inherit;
563
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
564
+ }
565
+
566
+ .bt-textarea::placeholder {
567
+ color: #64748b;
568
+ }
569
+
570
+ .bt-textarea:focus {
571
+ border-color: #3b82f6;
572
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
573
+ }
574
+
575
+ .bt-modal-footer {
576
+ display: flex;
577
+ justify-content: space-between;
578
+ align-items: center;
579
+ gap: 12px;
580
+ margin-top: 2px;
581
+ }
582
+
583
+ .bt-kbd-hint {
584
+ font-size: 11px;
585
+ color: #64748b;
586
+ display: flex;
587
+ align-items: center;
588
+ gap: 4px;
589
+ user-select: none;
590
+ }
591
+
592
+ .bt-kbd-hint kbd {
593
+ background: #1e293b;
594
+ color: #94a3b8;
595
+ border: 1px solid #334155;
596
+ border-radius: 4px;
597
+ padding: 1px 5px;
598
+ font-family: inherit;
599
+ font-size: 10px;
600
+ font-weight: 600;
601
+ }
602
+
603
+ .bt-modal-actions {
604
+ display: flex;
605
+ justify-content: flex-end;
606
+ align-items: center;
607
+ gap: 8px;
608
+ }
609
+
610
+ .bt-btn {
611
+ padding: 8px 16px;
612
+ border-radius: 7px;
613
+ font-size: 12.5px;
614
+ font-weight: 600;
615
+ cursor: pointer;
616
+ border: 1px solid transparent;
617
+ transition: all 0.15s ease;
618
+ font-family: inherit;
619
+ display: inline-flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ gap: 6px;
623
+ }
624
+
625
+ .bt-btn-cancel {
626
+ background: transparent;
627
+ color: #94a3b8;
628
+ }
629
+ .bt-btn-cancel:hover {
630
+ background: #1e293b;
631
+ color: #f8fafc;
632
+ }
633
+
634
+ .bt-btn-save {
635
+ background: #2563eb;
636
+ color: #ffffff;
637
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.35);
638
+ }
639
+ .bt-btn-save:hover {
640
+ background: #1d4ed8;
641
+ box-shadow: 0 4px 10px rgba(37, 99, 235, 0.45);
642
+ }
643
+
644
+ .bt-btn-resolve {
645
+ background: rgba(16, 185, 129, 0.15);
646
+ color: #6ee7b7;
647
+ border: 1px solid rgba(16, 185, 129, 0.35);
648
+ }
649
+ .bt-btn-resolve:hover {
650
+ background: rgba(16, 185, 129, 0.3);
651
+ color: #ffffff;
652
+ }
653
+
654
+ .bt-btn-delete {
655
+ background: rgba(239, 68, 68, 0.15);
656
+ color: #fca5a5;
657
+ border: 1px solid rgba(239, 68, 68, 0.35);
658
+ }
659
+ .bt-btn-delete:hover {
660
+ background: rgba(239, 68, 68, 0.3);
661
+ color: #ffffff;
662
+ }
663
+ `;
664
+
665
+ this.shadowRoot.appendChild(style);
666
+
667
+ // Element Highlight Overlay
668
+ this.highlightOverlay = document.createElement('div');
669
+ this.highlightOverlay.className = 'bt-highlight';
670
+ this.highlightOverlay.style.display = 'none';
671
+ this.shadowRoot.appendChild(this.highlightOverlay);
672
+
673
+ // Markers container layer
674
+ this.markersContainer = document.createElement('div');
675
+ this.markersContainer.className = 'bt-markers-layer';
676
+ this.shadowRoot.appendChild(this.markersContainer);
677
+
678
+ // Floating Toolbar
679
+ if (this.options.showToolbar) {
680
+ this.createToolbar();
681
+ }
682
+ }
683
+
684
+ if (this.container && !this.container.isConnected) {
685
+ const mountParent = document.body || document.documentElement;
686
+ if (mountParent) {
687
+ mountParent.appendChild(this.container);
688
+ }
689
+ }
690
+
691
+ return this.shadowRoot;
692
+ }
693
+
694
+ private createToolbar(): void {
695
+ if (!this.shadowRoot || this.toolbarElement) return;
696
+
697
+ this.toolbarElement = document.createElement('div');
698
+ this.toolbarElement.className = 'bt-toolbar';
699
+ this.toolbarElement.innerHTML = `
700
+ <button class="bt-toolbar-btn" id="bt-mode-element" title="Inspect element (or hold Alt+Click)">
701
+ <span>🎯</span> Element
702
+ </button>
703
+ <button class="bt-toolbar-btn" id="bt-mode-region" title="Drag to select screen region">
704
+ <span>📐</span> Region
705
+ </button>
706
+ <button class="bt-toolbar-btn" id="bt-mode-page" title="Leave full-page note">
707
+ <span>📄</span> Page
708
+ </button>
709
+ <div class="bt-toolbar-divider"></div>
710
+ <button class="bt-toolbar-btn active" id="bt-toggle-notes" title="Toggle visible note markers on screen">
711
+ <span>📌</span> Notes <span class="bt-count-pill" id="bt-notes-count">0</span>
712
+ </button>
713
+ `;
714
+
715
+ const btnElement = this.toolbarElement.querySelector('#bt-mode-element') as HTMLButtonElement;
716
+ const btnRegion = this.toolbarElement.querySelector('#bt-mode-region') as HTMLButtonElement;
717
+ const btnPage = this.toolbarElement.querySelector('#bt-mode-page') as HTMLButtonElement;
718
+ const btnToggleNotes = this.toolbarElement.querySelector('#bt-toggle-notes') as HTMLButtonElement;
719
+
720
+ btnElement.onclick = (e: MouseEvent) => {
721
+ e.stopPropagation();
722
+ this.setMode(this.activeMode === 'element' ? 'idle' : 'element');
723
+ };
724
+
725
+ btnRegion.onclick = (e: MouseEvent) => {
726
+ e.stopPropagation();
727
+ this.setMode(this.activeMode === 'region' ? 'idle' : 'region');
728
+ };
729
+
730
+ btnPage.onclick = (e: MouseEvent) => {
731
+ e.stopPropagation();
732
+ this.setMode('page');
733
+ };
734
+
735
+ btnToggleNotes.onclick = (e: MouseEvent) => {
736
+ e.stopPropagation();
737
+ this.showMarkers = !this.showMarkers;
738
+ btnToggleNotes.classList.toggle('active', this.showMarkers);
739
+ this.renderMarkers();
740
+ };
741
+
742
+ this.shadowRoot.appendChild(this.toolbarElement);
743
+ }
744
+
745
+ private updateToolbarState(): void {
746
+ if (!this.toolbarElement) return;
747
+
748
+ const btnElement = this.toolbarElement.querySelector('#bt-mode-element');
749
+ const btnRegion = this.toolbarElement.querySelector('#bt-mode-region');
750
+ const btnPage = this.toolbarElement.querySelector('#bt-mode-page');
751
+
752
+ btnElement?.classList.toggle('active', this.activeMode === 'element');
753
+ btnRegion?.classList.toggle('active', this.activeMode === 'region');
754
+ btnPage?.classList.toggle('active', this.activeMode === 'page');
755
+ }
756
+
757
+ private updateToolbarCount(): void {
758
+ if (!this.toolbarElement) return;
759
+ const countEl = this.toolbarElement.querySelector('#bt-notes-count');
760
+ if (countEl) {
761
+ const openCount = this.savedNotes.filter((n) => n.status === 'OPEN').length;
762
+ countEl.textContent = String(openCount);
763
+ }
764
+ }
765
+
766
+ public renderMarkers(): void {
767
+ const root = this.ensureContainer();
768
+ if (!root || !this.markersContainer) return;
769
+
770
+ this.markersContainer.innerHTML = '';
771
+ if (!this.showMarkers) return;
772
+
773
+ const currentPath = window.location.pathname;
774
+ // Filter open notes on this route or global
775
+ const activeNotes = this.savedNotes.filter(
776
+ (n) => n.status === 'OPEN' && (n.route === currentPath || !n.route || n.route === '/' || window.location.href.includes(n.route))
777
+ );
778
+
779
+ const pageNotes: VisualNote[] = [];
780
+
781
+ activeNotes.forEach((note, index) => {
782
+ if (note.type === 'page') {
783
+ pageNotes.push(note);
784
+ return;
785
+ }
786
+
787
+ if (note.type === 'region' && note.region) {
788
+ // Region box
789
+ const regBox = document.createElement('div');
790
+ regBox.className = 'bt-region-marker-box';
791
+ regBox.style.left = `${note.region.x}px`;
792
+ regBox.style.top = `${note.region.y}px`;
793
+ regBox.style.width = `${note.region.width}px`;
794
+ regBox.style.height = `${note.region.height}px`;
795
+ this.markersContainer!.appendChild(regBox);
796
+
797
+ // Pin on top-left of region
798
+ const pin = document.createElement('div');
799
+ pin.className = 'bt-note-marker';
800
+ pin.style.left = `${Math.max(4, note.region.x - 12)}px`;
801
+ pin.style.top = `${Math.max(4, note.region.y - 12)}px`;
802
+ pin.title = note.message;
803
+ pin.innerHTML = `<span>📐</span> <span>#${index + 1}</span>`;
804
+ pin.onclick = (e) => {
805
+ e.stopPropagation();
806
+ this.openNoteCard(note);
807
+ };
808
+ this.markersContainer!.appendChild(pin);
809
+ return;
810
+ }
811
+
812
+ // Element note
813
+ let targetEl: HTMLElement | null = null;
814
+ if (note.target?.selector) {
815
+ try {
816
+ targetEl = document.querySelector(note.target.selector) as HTMLElement | null;
817
+ } catch {}
818
+ }
819
+
820
+ const rect = targetEl ? targetEl.getBoundingClientRect() : note.target?.boundingRect;
821
+ if (rect) {
822
+ const pin = document.createElement('div');
823
+ pin.className = 'bt-note-marker';
824
+ pin.setAttribute('data-note-id', note.id);
825
+ pin.title = note.message;
826
+ pin.style.left = `${Math.max(4, rect.left - 10)}px`;
827
+ pin.style.top = `${Math.max(4, rect.top - 12)}px`;
828
+ pin.innerHTML = `<span>📝</span> <span>#${index + 1}</span>`;
829
+
830
+ pin.onclick = (e) => {
831
+ e.stopPropagation();
832
+ this.openNoteCard(note);
833
+ };
834
+
835
+ if (targetEl) {
836
+ pin.onmouseenter = () => this.updateHighlight(targetEl!);
837
+ pin.onmouseleave = () => this.hideHighlight();
838
+ }
839
+
840
+ this.markersContainer!.appendChild(pin);
841
+ }
842
+ });
843
+
844
+ // Page notes docked in top-right
845
+ if (pageNotes.length > 0) {
846
+ const pageDock = document.createElement('div');
847
+ pageDock.className = 'bt-page-notes-dock';
848
+ pageNotes.forEach((pNote, idx) => {
849
+ const pill = document.createElement('div');
850
+ pill.className = 'bt-page-note-pill';
851
+ pill.innerHTML = `<span>📄</span> <span>Page Note (${truncate(pNote.message, 25)})</span>`;
852
+ pill.onclick = (e) => {
853
+ e.stopPropagation();
854
+ this.openNoteCard(pNote);
855
+ };
856
+ pageDock.appendChild(pill);
857
+ });
858
+ this.markersContainer.appendChild(pageDock);
859
+ }
860
+ }
861
+
862
+ private updateMarkerPositions(): void {
863
+ if (!this.showMarkers || !this.markersContainer) return;
864
+ this.renderMarkers();
865
+ }
866
+
867
+ public openNoteCard(note: VisualNote): void {
868
+ const root = this.ensureContainer();
869
+ if (!root) return;
870
+
871
+ if (this.cardOverlay && this.cardOverlay.parentElement) {
872
+ this.cardOverlay.parentElement.removeChild(this.cardOverlay);
873
+ this.cardOverlay = null;
874
+ }
875
+
876
+ const backdrop = document.createElement('div');
877
+ backdrop.className = 'bt-modal-backdrop';
878
+ this.cardOverlay = backdrop;
879
+
880
+ const badgeClass = `bt-badge-${note.type.toLowerCase()}`;
881
+ const statusClass = note.status === 'OPEN' ? 'bt-badge-status-open' : 'bt-badge-status-resolved';
882
+ const icon = note.type === 'region' ? '📐' : note.type === 'page' ? '📄' : '🎯';
883
+ const contextText =
884
+ note.type === 'region' && note.region
885
+ ? `Region: ${note.region.width} × ${note.region.height} px · Route: ${note.route}`
886
+ : note.type === 'page'
887
+ ? `Page Note · Route: ${note.route}`
888
+ : `${note.target?.selector || 'Element'} (${note.target?.boundingRect?.width || 0}×${note.target?.boundingRect?.height || 0}px) · ${note.route}`;
889
+
890
+ const formattedDate = new Date(note.createdAt).toLocaleString();
891
+
892
+ backdrop.innerHTML = `
893
+ <div class="bt-modal">
894
+ <div class="bt-modal-header">
895
+ <div class="bt-modal-title">
896
+ <span>${icon} Visual Note Details</span>
897
+ <span class="bt-mode-badge ${badgeClass}">${note.type.toUpperCase()}</span>
898
+ <span class="bt-mode-badge ${statusClass}">${note.status}</span>
899
+ </div>
900
+ <button class="bt-close-btn" id="btn-card-close" title="Close (Esc)">✕</button>
901
+ </div>
902
+
903
+ <div class="bt-target-pill" title="${contextText}">
904
+ <span>🏷️</span>
905
+ <span class="bt-pill-content">${contextText}</span>
906
+ </div>
907
+
908
+ <div class="bt-note-message-box">${note.message}</div>
909
+
910
+ <div style="font-size: 11px; color: #64748b; display: flex; justify-content: space-between; padding: 0 2px;">
911
+ <span>Created: ${formattedDate}</span>
912
+ <span>ID: <code style="font-family: monospace; color: #94a3b8;">${note.id}</code></span>
913
+ </div>
914
+
915
+ <div class="bt-modal-footer">
916
+ <button class="bt-btn bt-btn-delete" id="btn-card-delete" title="Delete this note">
917
+ <span>🗑️</span> Delete
918
+ </button>
919
+ <div class="bt-modal-actions">
920
+ <button class="bt-btn bt-btn-cancel" id="btn-card-dismiss">Close</button>
921
+ <button class="bt-btn ${note.status === 'OPEN' ? 'bt-btn-resolve' : 'bt-btn-save'}" id="btn-card-resolve">
922
+ ${note.status === 'OPEN' ? '<span>✅</span> Resolve Note' : '<span>↺</span> Reopen'}
923
+ </button>
924
+ </div>
925
+ </div>
926
+ </div>
927
+ `;
928
+
929
+ const btnClose = backdrop.querySelector('#btn-card-close') as HTMLButtonElement;
930
+ const btnDismiss = backdrop.querySelector('#btn-card-dismiss') as HTMLButtonElement;
931
+ const btnResolve = backdrop.querySelector('#btn-card-resolve') as HTMLButtonElement;
932
+ const btnDelete = backdrop.querySelector('#btn-card-delete') as HTMLButtonElement;
933
+
934
+ const closeCard = () => {
935
+ if (this.cardOverlay) {
936
+ root.removeChild(this.cardOverlay);
937
+ this.cardOverlay = null;
938
+ this.hideHighlight();
939
+ }
940
+ };
941
+
942
+ btnClose.onclick = closeCard;
943
+ btnDismiss.onclick = closeCard;
944
+ backdrop.onclick = (e) => {
945
+ if (e.target === backdrop) closeCard();
946
+ };
947
+
948
+ btnResolve.onclick = () => {
949
+ if (note.status === 'OPEN') {
950
+ this.transport.send({ type: 'resolve_note', noteId: note.id });
951
+ } else {
952
+ this.transport.send({ type: 'reopen_note', noteId: note.id });
953
+ }
954
+ closeCard();
955
+ };
956
+
957
+ btnDelete.onclick = () => {
958
+ this.transport.send({ type: 'delete_note', noteId: note.id });
959
+ closeCard();
960
+ };
961
+
962
+ root.appendChild(backdrop);
963
+ }
964
+
965
+ private setupListeners(): void {
966
+ // 1. Mouse move: hover highlight in element mode or with Alt key
967
+ const onMouseMove = (e: MouseEvent) => {
968
+ if (this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
969
+
970
+ // Don't highlight elements if mouse is over our own inspector UI
971
+ const path = e.composedPath ? e.composedPath() : [];
972
+ if (this.container && path.includes(this.container)) {
973
+ this.hideHighlight();
974
+ return;
975
+ }
976
+
977
+ if (e.altKey || this.activeMode === 'element') {
978
+ const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
979
+ if (target && target !== this.container && !this.container?.contains(target)) {
980
+ this.hoveredElement = target;
981
+ this.updateHighlight(target);
982
+ return;
983
+ }
984
+ }
985
+
986
+ if (!e.altKey && this.activeMode !== 'element') {
987
+ this.hideHighlight();
988
+ }
989
+ };
990
+
991
+ // 2. Click: Alt+Click or Element mode click selects element and opens editor
992
+ const onClick = (e: MouseEvent) => {
993
+ if (this.modalOverlay || this.cardOverlay) return;
994
+
995
+ // If clicking inside inspector toolbar or container, do NOT intercept
996
+ const path = e.composedPath ? e.composedPath() : [];
997
+ if (this.container && path.includes(this.container)) {
998
+ return;
999
+ }
1000
+
1001
+ if (this.activeMode === 'region') return;
1002
+
1003
+ if (e.altKey || this.activeMode === 'element') {
1004
+ e.preventDefault();
1005
+ e.stopPropagation();
1006
+
1007
+ const target = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
1008
+ if (target && target !== this.container && !this.container?.contains(target)) {
1009
+ this.selectedElement = target;
1010
+ this.openNoteEditor(target, 'element');
1011
+ if (this.activeMode === 'element') {
1012
+ this.setMode('idle');
1013
+ }
1014
+ }
1015
+ }
1016
+ };
1017
+
1018
+ // 3. Key handling: Escape cancels active modes, Alt release hides highlight
1019
+ const onKeyDown = (e: KeyboardEvent) => {
1020
+ if (e.key === 'Escape') {
1021
+ if (this.cardOverlay && this.cardOverlay.parentElement && this.shadowRoot) {
1022
+ this.shadowRoot.removeChild(this.cardOverlay);
1023
+ this.cardOverlay = null;
1024
+ } else if (this.activeMode === 'region' || this.activeMode === 'element') {
1025
+ this.setMode('idle');
1026
+ }
1027
+ }
1028
+ };
1029
+
1030
+ const onKeyUp = (e: KeyboardEvent) => {
1031
+ if (e.key === 'Alt' && !this.modalOverlay && !this.cardOverlay && this.activeMode !== 'element') {
1032
+ this.hideHighlight();
1033
+ }
1034
+ };
1035
+
1036
+ // 4. Scroll & resize: reposition markers accurately
1037
+ const onScrollOrResize = () => {
1038
+ requestAnimationFrame(() => {
1039
+ this.updateMarkerPositions();
1040
+ });
1041
+ };
1042
+
1043
+ window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
1044
+ window.addEventListener('click', onClick, { capture: true });
1045
+ window.addEventListener('keydown', onKeyDown, { capture: true });
1046
+ window.addEventListener('keyup', onKeyUp, { capture: true });
1047
+ window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true });
1048
+ window.addEventListener('resize', onScrollOrResize, { passive: true });
1049
+
1050
+ this.cleanups.push(() => {
1051
+ window.removeEventListener('mousemove', onMouseMove, { capture: true });
1052
+ window.removeEventListener('click', onClick, { capture: true });
1053
+ window.removeEventListener('keydown', onKeyDown, { capture: true });
1054
+ window.removeEventListener('keyup', onKeyUp, { capture: true });
1055
+ window.removeEventListener('scroll', onScrollOrResize, { capture: true });
1056
+ window.removeEventListener('resize', onScrollOrResize);
1057
+ });
1058
+ }
1059
+
1060
+ private showRegionOverlay(): void {
1061
+ const root = this.ensureContainer();
1062
+ if (!root) return;
1063
+
1064
+ if (!this.regionOverlay) {
1065
+ this.regionOverlay = document.createElement('div');
1066
+ this.regionOverlay.className = 'bt-region-overlay';
1067
+
1068
+ this.regionBox = document.createElement('div');
1069
+ this.regionBox.className = 'bt-region-box';
1070
+ this.regionBox.style.display = 'none';
1071
+ this.regionOverlay.appendChild(this.regionBox);
1072
+
1073
+ // Top helper & cancel banner
1074
+ this.regionBanner = document.createElement('div');
1075
+ this.regionBanner.className = 'bt-region-banner';
1076
+ this.regionBanner.innerHTML = `
1077
+ <span>📐 Drag rectangle on screen to select region</span>
1078
+ <button class="bt-region-cancel-btn" id="bt-region-cancel">✕ Cancel (Esc)</button>
1079
+ `;
1080
+
1081
+ const btnCancel = this.regionBanner.querySelector('#bt-region-cancel') as HTMLButtonElement;
1082
+ btnCancel.onclick = (e: MouseEvent) => {
1083
+ e.stopPropagation();
1084
+ this.setMode('idle');
1085
+ };
1086
+
1087
+ this.regionOverlay.appendChild(this.regionBanner);
1088
+
1089
+ // Right-click cancels region mode
1090
+ this.regionOverlay.oncontextmenu = (e: MouseEvent) => {
1091
+ e.preventDefault();
1092
+ this.setMode('idle');
1093
+ };
1094
+
1095
+ // Drag events
1096
+ this.regionOverlay.onmousedown = (e: MouseEvent) => {
1097
+ const path = e.composedPath ? e.composedPath() : [];
1098
+ if (this.regionBanner && path.includes(this.regionBanner)) return;
1099
+ if (this.toolbarElement && path.includes(this.toolbarElement)) return;
1100
+
1101
+ this.isDraggingRegion = true;
1102
+ this.dragStartX = e.clientX;
1103
+ this.dragStartY = e.clientY;
1104
+ if (this.regionBox) {
1105
+ this.regionBox.style.display = 'block';
1106
+ this.regionBox.style.left = `${e.clientX}px`;
1107
+ this.regionBox.style.top = `${e.clientY}px`;
1108
+ this.regionBox.style.width = '0px';
1109
+ this.regionBox.style.height = '0px';
1110
+ }
1111
+ };
1112
+
1113
+ this.regionOverlay.onmousemove = (e: MouseEvent) => {
1114
+ if (!this.isDraggingRegion || !this.regionBox) return;
1115
+
1116
+ const currentX = e.clientX;
1117
+ const currentY = e.clientY;
1118
+
1119
+ const left = Math.min(this.dragStartX, currentX);
1120
+ const top = Math.min(this.dragStartY, currentY);
1121
+ const width = Math.abs(currentX - this.dragStartX);
1122
+ const height = Math.abs(currentY - this.dragStartY);
1123
+
1124
+ this.regionBox.style.left = `${left}px`;
1125
+ this.regionBox.style.top = `${top}px`;
1126
+ this.regionBox.style.width = `${width}px`;
1127
+ this.regionBox.style.height = `${height}px`;
1128
+
1129
+ let badge = this.regionBox.querySelector('.bt-region-badge') as HTMLDivElement | null;
1130
+ if (!badge) {
1131
+ badge = document.createElement('div');
1132
+ badge.className = 'bt-region-badge';
1133
+ this.regionBox.appendChild(badge);
1134
+ }
1135
+ badge.textContent = `Region: ${Math.round(width)} × ${Math.round(height)} px`;
1136
+ };
1137
+
1138
+ this.regionOverlay.onmouseup = (e: MouseEvent) => {
1139
+ if (!this.isDraggingRegion) return;
1140
+ this.isDraggingRegion = false;
1141
+
1142
+ const currentX = e.clientX;
1143
+ const currentY = e.clientY;
1144
+ const left = Math.min(this.dragStartX, currentX);
1145
+ const top = Math.min(this.dragStartY, currentY);
1146
+ const width = Math.abs(currentX - this.dragStartX);
1147
+ const height = Math.abs(currentY - this.dragStartY);
1148
+
1149
+ if (width > 15 && height > 15) {
1150
+ this.selectedRegion = {
1151
+ x: Math.round(left),
1152
+ y: Math.round(top),
1153
+ width: Math.round(width),
1154
+ height: Math.round(height),
1155
+ };
1156
+ this.hideRegionOverlay();
1157
+ this.setMode('idle');
1158
+ this.openRegionNoteEditor(this.selectedRegion);
1159
+ } else {
1160
+ this.hideRegionOverlay();
1161
+ this.setMode('idle');
1162
+ }
1163
+ };
1164
+ }
1165
+
1166
+ // Always ensure regionOverlay is mounted before toolbar so toolbar is interactable
1167
+ if (this.toolbarElement && this.toolbarElement.parentNode === root) {
1168
+ root.insertBefore(this.regionOverlay, this.toolbarElement);
1169
+ } else {
1170
+ root.appendChild(this.regionOverlay);
1171
+ }
1172
+ }
1173
+
1174
+ private hideRegionOverlay(): void {
1175
+ this.isDraggingRegion = false;
1176
+ if (this.regionOverlay && this.regionOverlay.parentNode) {
1177
+ this.regionOverlay.parentNode.removeChild(this.regionOverlay);
1178
+ }
1179
+ if (this.regionBox) {
1180
+ this.regionBox.style.display = 'none';
1181
+ }
1182
+ }
1183
+
1184
+ private updateHighlight(el: HTMLElement): void {
1185
+ const root = this.ensureContainer();
1186
+ if (!root || !this.highlightOverlay) return;
1187
+
1188
+ const rect = el.getBoundingClientRect();
1189
+ this.highlightOverlay.style.display = 'block';
1190
+ this.highlightOverlay.style.top = `${rect.top}px`;
1191
+ this.highlightOverlay.style.left = `${rect.left}px`;
1192
+ this.highlightOverlay.style.width = `${rect.width}px`;
1193
+ this.highlightOverlay.style.height = `${rect.height}px`;
1194
+
1195
+ const selector = getSemanticSelector(el);
1196
+ const badgeText = `${selector} · ${Math.round(rect.width)}×${Math.round(rect.height)}`;
1197
+
1198
+ let badge = this.highlightOverlay.querySelector('.bt-badge') as HTMLDivElement | null;
1199
+ if (!badge) {
1200
+ badge = document.createElement('div');
1201
+ badge.className = 'bt-badge';
1202
+ this.highlightOverlay.appendChild(badge);
1203
+ }
1204
+ badge.textContent = badgeText;
1205
+ }
1206
+
1207
+ private hideHighlight(): void {
1208
+ if (this.highlightOverlay) {
1209
+ this.highlightOverlay.style.display = 'none';
1210
+ }
1211
+ }
1212
+
1213
+ public openNoteEditor(el?: HTMLElement, noteType: 'element' | 'page' = 'element'): void {
1214
+ const root = this.ensureContainer();
1215
+ if (!root) return;
1216
+
1217
+ const targetEl = el || document.body;
1218
+ this.selectedElement = targetEl;
1219
+ if (noteType === 'element') {
1220
+ this.updateHighlight(targetEl);
1221
+ }
1222
+
1223
+ this.renderNoteModal({
1224
+ title: 'Add Visual Note',
1225
+ modeBadge: noteType.toUpperCase(),
1226
+ pillText:
1227
+ noteType === 'page'
1228
+ ? `Page Viewport: ${window.innerWidth} × ${window.innerHeight} px`
1229
+ : `${getSemanticSelector(targetEl)} (${Math.round(targetEl.getBoundingClientRect().width)}×${Math.round(targetEl.getBoundingClientRect().height)}) · Viewport: ${window.innerWidth}×${window.innerHeight}`,
1230
+ onSave: async (message) => {
1231
+ await this.saveVisualNote(targetEl, message, noteType);
1232
+ },
1233
+ });
1234
+ }
1235
+
1236
+ public openRegionNoteEditor(region: RegionContext): void {
1237
+ this.renderNoteModal({
1238
+ title: 'Add Region Note',
1239
+ modeBadge: 'REGION',
1240
+ pillText: `Selected Area: x:${region.x}, y:${region.y} (${region.width} × ${region.height} px)`,
1241
+ onSave: async (message) => {
1242
+ await this.saveRegionVisualNote(region, message);
1243
+ },
1244
+ });
1245
+ }
1246
+
1247
+ private renderNoteModal(options: {
1248
+ title: string;
1249
+ modeBadge: string;
1250
+ pillText: string;
1251
+ onSave: (message: string) => Promise<void>;
1252
+ }): void {
1253
+ const root = this.ensureContainer();
1254
+ if (!root) return;
1255
+
1256
+ // Remove existing modal if any
1257
+ if (this.modalOverlay && this.modalOverlay.parentElement) {
1258
+ this.modalOverlay.parentElement.removeChild(this.modalOverlay);
1259
+ this.modalOverlay = null;
1260
+ }
1261
+
1262
+ const backdrop = document.createElement('div');
1263
+ backdrop.className = 'bt-modal-backdrop';
1264
+ this.modalOverlay = backdrop;
1265
+
1266
+ const badgeClass = `bt-badge-${options.modeBadge.toLowerCase()}`;
1267
+ const icon = options.modeBadge === 'REGION' ? '📐' : options.modeBadge === 'PAGE' ? '📄' : '🎯';
1268
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
1269
+
1270
+ backdrop.innerHTML = `
1271
+ <div class="bt-modal">
1272
+ <div class="bt-modal-header">
1273
+ <div class="bt-modal-title">
1274
+ <span>📝 ${options.title}</span>
1275
+ <span class="bt-mode-badge ${badgeClass}">${options.modeBadge}</span>
1276
+ </div>
1277
+ <button class="bt-close-btn" id="btn-close" title="Close (Esc)">✕</button>
1278
+ </div>
1279
+ <div class="bt-target-pill" title="${options.pillText}">
1280
+ <span>${icon}</span>
1281
+ <span class="bt-pill-content">${options.pillText}</span>
1282
+ </div>
1283
+ <textarea class="bt-textarea" placeholder="Describe the layout issue, styling bug, or note for AI agent..." autofocus></textarea>
1284
+ <div class="bt-modal-footer">
1285
+ <div class="bt-kbd-hint">
1286
+ <kbd>${isMac ? '⌘' : 'Ctrl'}+Enter</kbd> save · <kbd>Esc</kbd> cancel
1287
+ </div>
1288
+ <div class="bt-modal-actions">
1289
+ <button class="bt-btn bt-btn-cancel" id="btn-cancel">Cancel</button>
1290
+ <button class="bt-btn bt-btn-save" id="btn-save">Save Note</button>
1291
+ </div>
1292
+ </div>
1293
+ </div>
1294
+ `;
1295
+
1296
+ const textarea = backdrop.querySelector('textarea') as HTMLTextAreaElement;
1297
+ const btnClose = backdrop.querySelector('#btn-close') as HTMLButtonElement;
1298
+ const btnCancel = backdrop.querySelector('#btn-cancel') as HTMLButtonElement;
1299
+ const btnSave = backdrop.querySelector('#btn-save') as HTMLButtonElement;
1300
+
1301
+ const closeModal = () => {
1302
+ if (this.modalOverlay) {
1303
+ root.removeChild(this.modalOverlay);
1304
+ this.modalOverlay = null;
1305
+ this.hideHighlight();
1306
+ this.hideRegionOverlay();
1307
+ }
1308
+ };
1309
+
1310
+ btnClose.onclick = closeModal;
1311
+ btnCancel.onclick = closeModal;
1312
+
1313
+ backdrop.onclick = (e: MouseEvent) => {
1314
+ if (e.target === backdrop) {
1315
+ closeModal();
1316
+ }
1317
+ };
1318
+
1319
+ const submitNote = async () => {
1320
+ const message = textarea.value.trim();
1321
+ if (!message) return;
1322
+
1323
+ btnSave.textContent = 'Saving...';
1324
+ btnSave.disabled = true;
1325
+
1326
+ try {
1327
+ await options.onSave(message);
1328
+ } finally {
1329
+ closeModal();
1330
+ }
1331
+ };
1332
+
1333
+ btnSave.onclick = submitNote;
1334
+
1335
+ textarea.onkeydown = (e: KeyboardEvent) => {
1336
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
1337
+ e.preventDefault();
1338
+ submitNote();
1339
+ }
1340
+ if (e.key === 'Escape') {
1341
+ closeModal();
1342
+ }
1343
+ };
1344
+
1345
+ root.appendChild(backdrop);
1346
+ setTimeout(() => textarea?.focus(), 50);
1347
+ }
1348
+
1349
+ public async saveVisualNote(targetEl: HTMLElement, message: string, noteType: 'element' | 'page' = 'element'): Promise<void> {
1350
+ const rect = targetEl.getBoundingClientRect();
1351
+ const selector = noteType === 'page' ? 'body' : getSemanticSelector(targetEl);
1352
+
1353
+ // 1. Capture screenshot via screenshotDriver
1354
+ let screenshotDataUrl: string | undefined;
1355
+ try {
1356
+ const snap = await this.screenshotDriver.captureElement(targetEl);
1357
+ if (snap.ok) {
1358
+ screenshotDataUrl = snap.dataUrl;
1359
+ }
1360
+ } catch {
1361
+ // Defensive
1362
+ }
1363
+
1364
+ // 2. Extract DOM Context
1365
+ const elementContext = this.extractElementContext(targetEl);
1366
+
1367
+ // 3. Extract Target
1368
+ const target: NoteTarget = {
1369
+ selector,
1370
+ boundingRect: {
1371
+ x: Math.round(rect.x),
1372
+ y: Math.round(rect.y),
1373
+ width: Math.round(rect.width),
1374
+ height: Math.round(rect.height),
1375
+ top: Math.round(rect.top),
1376
+ left: Math.round(rect.left),
1377
+ bottom: Math.round(rect.bottom),
1378
+ right: Math.round(rect.right),
1379
+ },
1380
+ visible: rect.width > 0 && rect.height > 0,
1381
+ confidence: selector.startsWith('[data-test') ? 'high' : selector.startsWith('#') ? 'medium' : 'low',
1382
+ };
1383
+
1384
+ const notePayload = {
1385
+ type: 'create_note',
1386
+ sessionId: this.transport.getSessionId() || '',
1387
+ noteType,
1388
+ message,
1389
+ route: window.location.pathname + window.location.search,
1390
+ url: window.location.href,
1391
+ viewport: {
1392
+ width: window.innerWidth,
1393
+ height: window.innerHeight,
1394
+ devicePixelRatio: window.devicePixelRatio || 1,
1395
+ },
1396
+ scroll: {
1397
+ scrollX: window.scrollX,
1398
+ scrollY: window.scrollY,
1399
+ },
1400
+ target,
1401
+ elementContext,
1402
+ screenshot: screenshotDataUrl,
1403
+ timestamp: Date.now(),
1404
+ };
1405
+
1406
+ this.transport.send(notePayload);
1407
+ if (this.options.onNoteCreated) {
1408
+ this.options.onNoteCreated(notePayload as any);
1409
+ }
1410
+ }
1411
+
1412
+ public async saveRegionVisualNote(region: RegionContext, message: string): Promise<void> {
1413
+ // 1. Capture full page screenshot and crop to region
1414
+ let screenshotDataUrl: string | undefined;
1415
+ try {
1416
+ const snap = await this.screenshotDriver.captureElement(document.body || document.documentElement);
1417
+ if (snap.ok && snap.dataUrl) {
1418
+ screenshotDataUrl = await this.cropDataUrl(snap.dataUrl, region);
1419
+ }
1420
+ } catch {
1421
+ // Defensive
1422
+ }
1423
+
1424
+ const notePayload = {
1425
+ type: 'create_note',
1426
+ sessionId: this.transport.getSessionId() || '',
1427
+ noteType: 'region',
1428
+ message,
1429
+ route: window.location.pathname + window.location.search,
1430
+ url: window.location.href,
1431
+ viewport: {
1432
+ width: window.innerWidth,
1433
+ height: window.innerHeight,
1434
+ devicePixelRatio: window.devicePixelRatio || 1,
1435
+ },
1436
+ scroll: {
1437
+ scrollX: window.scrollX,
1438
+ scrollY: window.scrollY,
1439
+ },
1440
+ region,
1441
+ screenshot: screenshotDataUrl,
1442
+ timestamp: Date.now(),
1443
+ };
1444
+
1445
+ this.transport.send(notePayload);
1446
+ if (this.options.onNoteCreated) {
1447
+ this.options.onNoteCreated(notePayload as any);
1448
+ }
1449
+ }
1450
+
1451
+ private async cropDataUrl(dataUrl: string, region: RegionContext): Promise<string> {
1452
+ return new Promise((resolve) => {
1453
+ const img = new Image();
1454
+ img.onload = () => {
1455
+ try {
1456
+ const canvas = document.createElement('canvas');
1457
+ canvas.width = region.width;
1458
+ canvas.height = region.height;
1459
+ const ctx = canvas.getContext('2d');
1460
+ if (!ctx) {
1461
+ resolve(dataUrl);
1462
+ return;
1463
+ }
1464
+
1465
+ const dpr = window.devicePixelRatio || 1;
1466
+ ctx.drawImage(
1467
+ img,
1468
+ region.x * dpr,
1469
+ region.y * dpr,
1470
+ region.width * dpr,
1471
+ region.height * dpr,
1472
+ 0,
1473
+ 0,
1474
+ region.width,
1475
+ region.height
1476
+ );
1477
+ resolve(canvas.toDataURL('image/webp', 0.9));
1478
+ } catch {
1479
+ resolve(dataUrl);
1480
+ }
1481
+ };
1482
+ img.onerror = () => resolve(dataUrl);
1483
+ img.src = dataUrl;
1484
+ });
1485
+ }
1486
+
1487
+ private extractElementContext(el: HTMLElement): ElementContext {
1488
+ const selector = getSemanticSelector(el);
1489
+ const tag = el.tagName.toLowerCase();
1490
+
1491
+ // Attributes (sanitize passwords/tokens)
1492
+ const attributes: Record<string, string> = {};
1493
+ for (let i = 0; i < el.attributes.length; i++) {
1494
+ const attr = el.attributes[i];
1495
+ if (attr.name === 'value' && (el as HTMLInputElement).type === 'password') {
1496
+ attributes[attr.name] = '[REDACTED]';
1497
+ } else {
1498
+ attributes[attr.name] = attr.value;
1499
+ }
1500
+ }
1501
+
1502
+ // Clone element to sanitize inner sensitive fields
1503
+ let outerHTML = '';
1504
+ try {
1505
+ const clone = el.cloneNode(true) as HTMLElement;
1506
+ for (const passInput of Array.from(clone.querySelectorAll('input[type="password"]'))) {
1507
+ passInput.setAttribute('value', '[REDACTED]');
1508
+ }
1509
+ outerHTML = truncate(clone.outerHTML, 10240); // 10 KB max
1510
+ } catch {
1511
+ outerHTML = truncate(el.outerHTML, 10240);
1512
+ }
1513
+
1514
+ let parent: { selector: string; tag: string } | undefined;
1515
+ if (el.parentElement && el.parentElement !== document.body) {
1516
+ parent = {
1517
+ selector: getSemanticSelector(el.parentElement),
1518
+ tag: el.parentElement.tagName.toLowerCase(),
1519
+ };
1520
+ }
1521
+
1522
+ return {
1523
+ selector,
1524
+ tag,
1525
+ attributes,
1526
+ outerHTML,
1527
+ innerText: truncate(el.textContent?.trim(), 200),
1528
+ parent,
1529
+ };
1530
+ }
1531
+
1532
+ public destroy(): void {
1533
+ for (const cleanup of this.cleanups) {
1534
+ try {
1535
+ cleanup();
1536
+ } catch {}
1537
+ }
1538
+ this.cleanups = [];
1539
+
1540
+ if (this.container && this.container.parentElement) {
1541
+ this.container.parentElement.removeChild(this.container);
1542
+ }
1543
+ this.container = null;
1544
+ this.shadowRoot = null;
1545
+ this.toolbarElement = null;
1546
+ this.regionOverlay = null;
1547
+ this.regionBox = null;
1548
+ this.regionBanner = null;
1549
+ this.markersContainer = null;
1550
+ this.modalOverlay = null;
1551
+ this.cardOverlay = null;
1552
+ }
1553
+ }