browsertrack 0.2.1 → 0.2.3

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 (55) hide show
  1. package/AGENTS.md +9 -6
  2. package/README.md +2 -0
  3. package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
  4. package/dist/chunk-5NR5K3ER.js.map +1 -0
  5. package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
  6. package/dist/chunk-G5CIZSQM.js.map +1 -0
  7. package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
  8. package/dist/chunk-ONPW7AYL.js.map +1 -0
  9. package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
  10. package/dist/chunk-PG4JJDCV.js.map +1 -0
  11. package/dist/cli/index.js +1058 -546
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/client/index.cjs +449 -128
  14. package/dist/client/index.d.ts +2 -0
  15. package/dist/client/index.js +2 -2
  16. package/dist/client.iife.js +121 -15
  17. package/dist/core/index.d.ts +32 -2
  18. package/dist/core/index.js +11 -1
  19. package/dist/daemon/index.d.ts +2 -2
  20. package/dist/daemon/index.js +6 -8
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +16 -7
  23. package/dist/mcp/index.d.ts +1 -1
  24. package/dist/mcp/index.js +7 -4
  25. package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
  26. package/docs/cli.md +4 -1
  27. package/docs/getting-started.md +60 -6
  28. package/docs/mcp-reference.md +42 -0
  29. package/package.json +1 -1
  30. package/packages/cli/src/index.ts +247 -151
  31. package/packages/client/src/interceptors/navigation.ts +38 -26
  32. package/packages/client/src/interceptors/network.ts +22 -17
  33. package/packages/client/src/notes/inspector.ts +289 -65
  34. package/packages/client/src/source/resolver.ts +9 -3
  35. package/packages/client/src/transport/websocket.ts +23 -18
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/safety.ts +86 -0
  38. package/packages/core/src/selector.ts +110 -15
  39. package/packages/daemon/src/server/daemon.ts +7 -1
  40. package/packages/daemon/src/server/http.ts +125 -5
  41. package/packages/daemon/src/server/ws.ts +33 -29
  42. package/packages/daemon/src/storage/db.ts +57 -35
  43. package/packages/mcp/src/handlers.ts +121 -45
  44. package/packages/mcp/src/server.ts +221 -3
  45. package/test/core/safety.test.ts +106 -0
  46. package/test/core/selector.test.ts +133 -0
  47. package/test/daemon/storage.test.ts +36 -0
  48. package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
  49. package/test/mcp/auto-start.test.ts +87 -0
  50. package/dist/chunk-3HOXPTM2.js.map +0 -1
  51. package/dist/chunk-464D4U2U.js.map +0 -1
  52. package/dist/chunk-6VA7GBAO.js.map +0 -1
  53. package/dist/chunk-7OCOQGDN.js +0 -635
  54. package/dist/chunk-7OCOQGDN.js.map +0 -1
  55. package/dist/chunk-INXDWPJW.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { ElementContext, NoteTarget, RegionContext, VisualNote } from '../../../core/src/index.js';
2
- import { getSemanticSelector, truncate } from '../../../core/src/index.js';
2
+ import { getSemanticSelector, resolveMeaningfulTarget, truncate } from '../../../core/src/index.js';
3
3
  import type { ScreenshotDriver } from '../screenshot/driver.js';
4
4
  import type { WebSocketTransport } from '../transport/websocket.js';
5
5
  import { shouldHideUIFromUrl } from '../config.js';
@@ -65,6 +65,7 @@ export class NoteInspector {
65
65
  private savedNotes: VisualNote[] = [];
66
66
  private showMarkers = true;
67
67
  private isHidden = false;
68
+ private isToolbarCollapsed = false;
68
69
  private cleanups: (() => void)[] = [];
69
70
 
70
71
  constructor(transport: WebSocketTransport, screenshotDriver: ScreenshotDriver, options: InspectorOptions = {}) {
@@ -73,6 +74,11 @@ export class NoteInspector {
73
74
  const hiddenByQuery = shouldHideUIFromUrl(options.hideQueryParam);
74
75
  this.isHidden = options.hidden === true || hiddenByQuery;
75
76
  this.showMarkers = options.showBadges !== false && !this.isHidden;
77
+ try {
78
+ if (typeof window !== 'undefined' && window.sessionStorage) {
79
+ this.isToolbarCollapsed = window.sessionStorage.getItem('bt_toolbar_collapsed') === 'true';
80
+ }
81
+ } catch {}
76
82
  this.options = {
77
83
  shortcut: 'Alt+Click',
78
84
  maskSelectors: ['input[type="password"]', '[data-sensitive]'],
@@ -224,11 +230,28 @@ export class NoteInspector {
224
230
  // Styles for highlight overlay, region drag box, markers, floating toolbar and modals
225
231
  const style = document.createElement('style');
226
232
  style.textContent = `
227
- :host {
228
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
229
- color-scheme: dark;
233
+ @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
234
+
235
+ :host, :host *, :host *::before, :host *::after {
236
+ box-sizing: border-box;
230
237
  -webkit-font-smoothing: antialiased;
231
238
  -moz-osx-font-smoothing: grayscale;
239
+ text-rendering: optimizeLegibility;
240
+ }
241
+
242
+ :host {
243
+ font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
244
+ color-scheme: dark;
245
+ letter-spacing: -0.012em;
246
+ }
247
+
248
+ button, input, textarea, select {
249
+ font-family: inherit;
250
+ letter-spacing: inherit;
251
+ }
252
+
253
+ code, pre, kbd, .bt-mono, .bt-badge, .bt-region-badge, .bt-target-pill, .bt-component-pill {
254
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
232
255
  }
233
256
 
234
257
  /* 1. Element Highlight Overlay */
@@ -462,7 +485,7 @@ export class NoteInspector {
462
485
  pointer-events: auto;
463
486
  z-index: 2147483646;
464
487
  user-select: none;
465
- transition: all 0.2s ease;
488
+ transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1);
466
489
  }
467
490
 
468
491
  .bt-toolbar-btn {
@@ -523,6 +546,86 @@ export class NoteInspector {
523
546
  margin: 0 2px;
524
547
  }
525
548
 
549
+ .bt-toolbar-collapse-btn {
550
+ background: transparent;
551
+ border: none;
552
+ color: #64748b;
553
+ font-size: 11px;
554
+ font-weight: 700;
555
+ padding: 6px 8px;
556
+ border-radius: 20px;
557
+ cursor: pointer;
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: center;
561
+ transition: all 0.15s ease;
562
+ font-family: inherit;
563
+ }
564
+
565
+ .bt-toolbar-collapse-btn:hover {
566
+ background: #1e293b;
567
+ color: #f8fafc;
568
+ }
569
+
570
+ /* Collapsed Toolbar State */
571
+ .bt-toolbar.bt-toolbar-collapsed {
572
+ padding: 0;
573
+ background: transparent;
574
+ border: none;
575
+ box-shadow: none;
576
+ }
577
+
578
+ .bt-toolbar.bt-toolbar-collapsed .bt-toolbar-btn,
579
+ .bt-toolbar.bt-toolbar-collapsed .bt-toolbar-divider,
580
+ .bt-toolbar.bt-toolbar-collapsed .bt-toolbar-collapse-btn {
581
+ display: none !important;
582
+ }
583
+
584
+ .bt-toolbar-trigger {
585
+ display: none;
586
+ background: #0f172a;
587
+ border: 1px solid #334155;
588
+ border-radius: 30px;
589
+ padding: 6px 14px;
590
+ color: #38bdf8;
591
+ font-size: 12px;
592
+ font-weight: 600;
593
+ cursor: pointer;
594
+ align-items: center;
595
+ gap: 8px;
596
+ box-shadow: 0 10px 25px -3px rgba(0,0,0,0.6), 0 4px 6px -4px rgba(0,0,0,0.4);
597
+ transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
598
+ font-family: inherit;
599
+ user-select: none;
600
+ }
601
+
602
+ .bt-toolbar.bt-toolbar-collapsed .bt-toolbar-trigger {
603
+ display: inline-flex;
604
+ }
605
+
606
+ .bt-toolbar-trigger:hover {
607
+ background: #1e293b;
608
+ color: #ffffff;
609
+ transform: translateY(-2px);
610
+ box-shadow: 0 12px 28px -3px rgba(0,0,0,0.7);
611
+ border-color: #38bdf8;
612
+ }
613
+
614
+ .bt-pulse-dot {
615
+ width: 7px;
616
+ height: 7px;
617
+ border-radius: 50%;
618
+ background: #38bdf8;
619
+ box-shadow: 0 0 8px #38bdf8;
620
+ display: inline-block;
621
+ animation: bt-dot-pulse 2s infinite;
622
+ }
623
+
624
+ @keyframes bt-dot-pulse {
625
+ 0%, 100% { opacity: 1; transform: scale(1); }
626
+ 50% { opacity: 0.4; transform: scale(0.85); }
627
+ }
628
+
526
629
  /* 5. Modals & Popover Card */
527
630
  .bt-modal-backdrop {
528
631
  position: fixed;
@@ -946,8 +1049,13 @@ export class NoteInspector {
946
1049
  if (!this.shadowRoot || this.toolbarElement) return;
947
1050
 
948
1051
  this.toolbarElement = document.createElement('div');
949
- this.toolbarElement.className = 'bt-toolbar';
1052
+ this.toolbarElement.className = `bt-toolbar ${this.isToolbarCollapsed ? 'bt-toolbar-collapsed' : ''}`;
950
1053
  this.toolbarElement.innerHTML = `
1054
+ <button class="bt-toolbar-trigger" id="bt-toolbar-expand" title="Expand BrowserTrack toolbar (Alt+T)">
1055
+ <span class="bt-pulse-dot"></span>
1056
+ <span>⚡ BrowserTrack</span>
1057
+ <span class="bt-count-pill" id="bt-notes-count-collapsed">0</span>
1058
+ </button>
951
1059
  <button class="bt-toolbar-btn" id="bt-mode-element" title="Inspect element (or hold Alt+Click)">
952
1060
  <span>🎯</span> Element
953
1061
  </button>
@@ -965,14 +1073,30 @@ export class NoteInspector {
965
1073
  <button class="bt-toolbar-btn active" id="bt-toggle-notes" title="Toggle visible note markers on screen">
966
1074
  <span>📌</span> Notes <span class="bt-count-pill" id="bt-notes-count">0</span>
967
1075
  </button>
1076
+ <div class="bt-toolbar-divider"></div>
1077
+ <button class="bt-toolbar-collapse-btn" id="bt-toolbar-collapse" title="Collapse toolbar (Alt+T)">
1078
+ <span>❯</span>
1079
+ </button>
968
1080
  `;
969
1081
 
1082
+ const btnExpand = this.toolbarElement.querySelector('#bt-toolbar-expand') as HTMLButtonElement;
1083
+ const btnCollapse = this.toolbarElement.querySelector('#bt-toolbar-collapse') as HTMLButtonElement;
970
1084
  const btnElement = this.toolbarElement.querySelector('#bt-mode-element') as HTMLButtonElement;
971
1085
  const btnRegion = this.toolbarElement.querySelector('#bt-mode-region') as HTMLButtonElement;
972
1086
  const btnPage = this.toolbarElement.querySelector('#bt-mode-page') as HTMLButtonElement;
973
1087
  const btnFlow = this.toolbarElement.querySelector('#bt-mode-flow') as HTMLButtonElement;
974
1088
  const btnToggleNotes = this.toolbarElement.querySelector('#bt-toggle-notes') as HTMLButtonElement;
975
1089
 
1090
+ btnExpand.onclick = (e: MouseEvent) => {
1091
+ e.stopPropagation();
1092
+ this.toggleToolbarCollapse(false);
1093
+ };
1094
+
1095
+ btnCollapse.onclick = (e: MouseEvent) => {
1096
+ e.stopPropagation();
1097
+ this.toggleToolbarCollapse(true);
1098
+ };
1099
+
976
1100
  btnElement.onclick = (e: MouseEvent) => {
977
1101
  e.stopPropagation();
978
1102
  this.setMode(this.activeMode === 'element' ? 'idle' : 'element');
@@ -1007,6 +1131,17 @@ export class NoteInspector {
1007
1131
  this.shadowRoot.appendChild(this.toolbarElement);
1008
1132
  }
1009
1133
 
1134
+ public toggleToolbarCollapse(force?: boolean): void {
1135
+ if (!this.toolbarElement) return;
1136
+ this.isToolbarCollapsed = typeof force === 'boolean' ? force : !this.isToolbarCollapsed;
1137
+ this.toolbarElement.classList.toggle('bt-toolbar-collapsed', this.isToolbarCollapsed);
1138
+ try {
1139
+ if (typeof window !== 'undefined' && window.sessionStorage) {
1140
+ window.sessionStorage.setItem('bt_toolbar_collapsed', String(this.isToolbarCollapsed));
1141
+ }
1142
+ } catch {}
1143
+ }
1144
+
1010
1145
  private updateToolbarState(): void {
1011
1146
  if (!this.toolbarElement) return;
1012
1147
 
@@ -1034,11 +1169,15 @@ export class NoteInspector {
1034
1169
 
1035
1170
  private updateToolbarCount(): void {
1036
1171
  if (!this.toolbarElement) return;
1172
+ const openCount = this.savedNotes.filter((n) => n.status === 'OPEN').length;
1037
1173
  const countEl = this.toolbarElement.querySelector('#bt-notes-count');
1038
1174
  if (countEl) {
1039
- const openCount = this.savedNotes.filter((n) => n.status === 'OPEN').length;
1040
1175
  countEl.textContent = String(openCount);
1041
1176
  }
1177
+ const countCollapsedEl = this.toolbarElement.querySelector('#bt-notes-count-collapsed');
1178
+ if (countCollapsedEl) {
1179
+ countCollapsedEl.textContent = String(openCount);
1180
+ }
1042
1181
  }
1043
1182
 
1044
1183
  public renderMarkers(): void {
@@ -1050,9 +1189,16 @@ export class NoteInspector {
1050
1189
 
1051
1190
  const currentPath = window.location.pathname;
1052
1191
  // Filter open notes on this route or global
1053
- const activeNotes = this.savedNotes.filter(
1054
- (n) => n.status === 'OPEN' && (n.route === currentPath || !n.route || n.route === '/' || window.location.href.includes(n.route))
1055
- );
1192
+ const activeNotes = this.savedNotes.filter((n) => {
1193
+ if (n.status !== 'OPEN') return false;
1194
+ if (!n.route) return true;
1195
+ if (n.route === currentPath) return true;
1196
+ // Support hash or query routes (e.g. /#modal or /?tab=settings)
1197
+ if (n.route.includes('#') || n.route.includes('?')) {
1198
+ return window.location.href.includes(n.route);
1199
+ }
1200
+ return false;
1201
+ });
1056
1202
 
1057
1203
  const pageNotes: VisualNote[] = [];
1058
1204
 
@@ -1101,6 +1247,14 @@ export class NoteInspector {
1101
1247
 
1102
1248
  const rect = targetEl ? targetEl.getBoundingClientRect() : note.target?.boundingRect;
1103
1249
  if (rect) {
1250
+ // If element is completely scrolled out of view, do not display or pin it to corner
1251
+ const isOffscreen =
1252
+ targetEl &&
1253
+ (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth);
1254
+ if (isOffscreen) {
1255
+ return;
1256
+ }
1257
+
1104
1258
  const pin = document.createElement('div');
1105
1259
  pin.className = markerClass;
1106
1260
  pin.setAttribute('data-note-id', note.id);
@@ -1343,81 +1497,111 @@ export class NoteInspector {
1343
1497
  private setupListeners(): void {
1344
1498
  // 1. Mouse move: hover highlight in element mode or with Alt key
1345
1499
  const onMouseMove = (e: MouseEvent) => {
1346
- if (this.isHidden || this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1347
-
1348
- // Don't highlight elements if mouse is over our own inspector UI
1349
- const path = e.composedPath ? e.composedPath() : [];
1350
- if (this.container && path.includes(this.container)) {
1351
- this.hideHighlight();
1352
- return;
1353
- }
1500
+ try {
1501
+ if (this.isHidden || this.modalOverlay || this.cardOverlay || this.isDraggingRegion || this.activeMode === 'region') return;
1354
1502
 
1355
- if (e.altKey || this.activeMode === 'element') {
1356
- const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
1357
- if (target && target !== this.container && !this.container?.contains(target)) {
1358
- this.hoveredElement = target;
1359
- this.updateHighlight(target);
1503
+ // Don't highlight elements if mouse is over our own inspector UI
1504
+ const path = e.composedPath ? e.composedPath() : [];
1505
+ if (this.container && path.includes(this.container)) {
1506
+ this.hideHighlight();
1360
1507
  return;
1361
1508
  }
1362
- }
1363
1509
 
1364
- if (!e.altKey && this.activeMode !== 'element') {
1365
- this.hideHighlight();
1510
+ if (e.altKey || this.activeMode === 'element') {
1511
+ const rawTarget = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
1512
+ const target = rawTarget ? resolveMeaningfulTarget(rawTarget) : null;
1513
+ if (target && target !== this.container && !this.container?.contains(target)) {
1514
+ this.hoveredElement = target;
1515
+ this.updateHighlight(target);
1516
+ return;
1517
+ }
1518
+ }
1519
+
1520
+ if (!e.altKey && this.activeMode !== 'element') {
1521
+ this.hideHighlight();
1522
+ }
1523
+ } catch {
1524
+ // Defensive: never let inspector hover crash host app
1366
1525
  }
1367
1526
  };
1368
1527
 
1369
1528
  // 2. Click: Alt+Click or Element mode click selects element and opens editor
1370
1529
  const onClick = (e: MouseEvent) => {
1371
- if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
1530
+ try {
1531
+ if (this.isHidden || this.modalOverlay || this.cardOverlay) return;
1372
1532
 
1373
- // If clicking inside inspector toolbar or container, do NOT intercept
1374
- const path = e.composedPath ? e.composedPath() : [];
1375
- if (this.container && path.includes(this.container)) {
1376
- return;
1377
- }
1533
+ // If clicking inside inspector toolbar or container, do NOT intercept
1534
+ const path = e.composedPath ? e.composedPath() : [];
1535
+ if (this.container && path.includes(this.container)) {
1536
+ return;
1537
+ }
1378
1538
 
1379
- if (this.activeMode === 'region') return;
1539
+ if (this.activeMode === 'region') return;
1380
1540
 
1381
- if (e.altKey || this.activeMode === 'element') {
1382
- e.preventDefault();
1383
- e.stopPropagation();
1541
+ if (e.altKey || this.activeMode === 'element') {
1542
+ e.preventDefault();
1543
+ e.stopPropagation();
1384
1544
 
1385
- const target = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
1386
- if (target && target !== this.container && !this.container?.contains(target)) {
1387
- this.selectedElement = target;
1388
- this.openNoteEditor(target, 'element');
1389
- if (this.activeMode === 'element' && !this.activeScenario) {
1390
- this.setMode('idle');
1545
+ const rawTarget = (this.hoveredElement || document.elementFromPoint(e.clientX, e.clientY)) as HTMLElement | null;
1546
+ const target = rawTarget ? resolveMeaningfulTarget(rawTarget) : null;
1547
+ if (target && target !== this.container && !this.container?.contains(target)) {
1548
+ this.selectedElement = target;
1549
+ this.openNoteEditor(target, 'element');
1550
+ if (this.activeMode === 'element' && !this.activeScenario) {
1551
+ this.setMode('idle');
1552
+ }
1391
1553
  }
1392
1554
  }
1555
+ } catch {
1556
+ // Defensive
1393
1557
  }
1394
1558
  };
1395
1559
 
1396
- // 3. Key handling: Escape cancels active modes, Alt release hides highlight
1560
+ // 3. Key handling: Escape cancels active modes, Alt release hides highlight, Alt+T toggles toolbar
1397
1561
  const onKeyDown = (e: KeyboardEvent) => {
1398
- if (e.key === 'Escape') {
1399
- if (this.cardOverlay && this.cardOverlay.parentElement && this.shadowRoot) {
1400
- this.shadowRoot.removeChild(this.cardOverlay);
1401
- this.cardOverlay = null;
1402
- } else if (this.activeMode === 'region' || this.activeMode === 'element') {
1403
- if (!this.activeScenario) {
1404
- this.setMode('idle');
1562
+ try {
1563
+ if (e.altKey && (e.key === 't' || e.key === 'T')) {
1564
+ e.preventDefault();
1565
+ this.toggleToolbarCollapse();
1566
+ return;
1567
+ }
1568
+
1569
+ if (e.key === 'Escape') {
1570
+ if (this.cardOverlay && this.cardOverlay.parentElement && this.shadowRoot) {
1571
+ this.shadowRoot.removeChild(this.cardOverlay);
1572
+ this.cardOverlay = null;
1573
+ } else if (this.activeMode === 'region' || this.activeMode === 'element') {
1574
+ if (!this.activeScenario) {
1575
+ this.setMode('idle');
1576
+ }
1405
1577
  }
1406
1578
  }
1579
+ } catch {
1580
+ // Defensive
1407
1581
  }
1408
1582
  };
1409
1583
 
1410
1584
  const onKeyUp = (e: KeyboardEvent) => {
1411
- if (e.key === 'Alt' && !this.modalOverlay && !this.cardOverlay && this.activeMode !== 'element') {
1412
- this.hideHighlight();
1585
+ try {
1586
+ if (e.key === 'Alt' && !this.modalOverlay && !this.cardOverlay && this.activeMode !== 'element') {
1587
+ this.hideHighlight();
1588
+ }
1589
+ } catch {
1590
+ // Defensive
1413
1591
  }
1414
1592
  };
1415
1593
 
1416
1594
  // 4. Scroll & resize: reposition markers accurately
1417
1595
  const onScrollOrResize = () => {
1418
- requestAnimationFrame(() => {
1419
- this.updateMarkerPositions();
1420
- });
1596
+ try {
1597
+ requestAnimationFrame(() => {
1598
+ try {
1599
+ this.updateMarkerPositions();
1600
+ } catch {}
1601
+ });
1602
+ } catch {
1603
+ // Defensive
1604
+ }
1421
1605
  };
1422
1606
 
1423
1607
  window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
@@ -1513,6 +1697,11 @@ export class NoteInspector {
1513
1697
  this.regionBox.appendChild(badge);
1514
1698
  }
1515
1699
  badge.textContent = `Region: ${Math.round(width)} × ${Math.round(height)} px`;
1700
+ if (top < 32) {
1701
+ badge.style.top = '4px';
1702
+ } else {
1703
+ badge.style.top = '-26px';
1704
+ }
1516
1705
  };
1517
1706
 
1518
1707
  this.regionOverlay.onmouseup = (e: MouseEvent) => {
@@ -1581,6 +1770,22 @@ export class NoteInspector {
1581
1770
  const selector = getSemanticSelector(el);
1582
1771
  const label = this.activeScenario ? `🎬 Step ${this.activeScenario.stepNumber} · ${selector}` : selector;
1583
1772
  badge.textContent = `${label} (${Math.round(rect.width)} × ${Math.round(rect.height)} px)`;
1773
+
1774
+ // Viewport-aware badge clamping: flip below element if near top of window
1775
+ if (rect.top < 32) {
1776
+ badge.style.top = `${rect.height + 4}px`;
1777
+ } else {
1778
+ badge.style.top = '-26px';
1779
+ }
1780
+
1781
+ // Align to right edge if overflowing the right viewport boundary
1782
+ if (rect.left + 260 > window.innerWidth) {
1783
+ badge.style.left = 'auto';
1784
+ badge.style.right = '0';
1785
+ } else {
1786
+ badge.style.left = '0';
1787
+ badge.style.right = 'auto';
1788
+ }
1584
1789
  }
1585
1790
 
1586
1791
  private hideHighlight(): void {
@@ -1768,10 +1973,13 @@ export class NoteInspector {
1768
1973
  if (btnSave) btnSave.disabled = true;
1769
1974
  if (btnFinishFlow) btnFinishFlow.disabled = true;
1770
1975
 
1976
+ // Close modal immediately for instant, snappy user experience
1977
+ closeModal();
1978
+
1771
1979
  try {
1772
1980
  await options.onSave(message, action);
1773
- } finally {
1774
- closeModal();
1981
+ } catch {
1982
+ this.showToast('Failed to save note', '⚠️');
1775
1983
  }
1776
1984
  };
1777
1985
 
@@ -1806,11 +2014,15 @@ export class NoteInspector {
1806
2014
  const rect = targetEl.getBoundingClientRect();
1807
2015
  const selector = noteType === 'page' ? 'body' : getSemanticSelector(targetEl);
1808
2016
 
1809
- // 1. Capture screenshot via screenshotDriver
2017
+ // 1. Capture screenshot via screenshotDriver with fast timeout protection
1810
2018
  let screenshotDataUrl: string | undefined;
1811
2019
  try {
1812
- const snap = await this.screenshotDriver.captureElement(targetEl);
1813
- if (snap.ok) {
2020
+ const snapPromise = this.screenshotDriver.captureElement(targetEl);
2021
+ const timeoutPromise = new Promise<{ ok: boolean; dataUrl?: string }>((resolve) =>
2022
+ setTimeout(() => resolve({ ok: false }), 800)
2023
+ );
2024
+ const snap = await Promise.race([snapPromise, timeoutPromise]);
2025
+ if (snap && snap.ok && snap.dataUrl) {
1814
2026
  screenshotDataUrl = snap.dataUrl;
1815
2027
  }
1816
2028
  } catch {
@@ -1881,12 +2093,20 @@ export class NoteInspector {
1881
2093
  message: string,
1882
2094
  scenario?: { scenarioId?: string; stepNumber?: number; scenarioTitle?: string }
1883
2095
  ): Promise<void> {
1884
- // 1. Capture full page screenshot and crop to region
2096
+ // 1. Fast regional capture: capture element under region center rather than heavy whole-document serialization
1885
2097
  let screenshotDataUrl: string | undefined;
1886
2098
  try {
1887
- const snap = await this.screenshotDriver.captureElement(document.body || document.documentElement);
1888
- if (snap.ok && snap.dataUrl) {
1889
- screenshotDataUrl = await this.cropDataUrl(snap.dataUrl, region);
2099
+ const centerX = region.x + region.width / 2;
2100
+ const centerY = region.y + region.height / 2;
2101
+ const targetElement = (document.elementFromPoint(centerX, centerY) || document.body) as HTMLElement;
2102
+
2103
+ const snapPromise = this.screenshotDriver.captureElement(targetElement);
2104
+ const timeoutPromise = new Promise<{ ok: boolean; dataUrl?: string }>((resolve) =>
2105
+ setTimeout(() => resolve({ ok: false }), 800)
2106
+ );
2107
+ const snap = await Promise.race([snapPromise, timeoutPromise]);
2108
+ if (snap && snap.ok && snap.dataUrl) {
2109
+ screenshotDataUrl = snap.dataUrl;
1890
2110
  }
1891
2111
  } catch {
1892
2112
  // Defensive
@@ -2005,7 +2225,11 @@ export class NoteInspector {
2005
2225
  region.width,
2006
2226
  region.height
2007
2227
  );
2008
- resolve(canvas.toDataURL('image/png'));
2228
+ try {
2229
+ resolve(canvas.toDataURL('image/png'));
2230
+ } catch {
2231
+ resolve(dataUrl);
2232
+ }
2009
2233
  };
2010
2234
  img.onerror = () => resolve(dataUrl);
2011
2235
  img.src = dataUrl;
@@ -76,7 +76,9 @@ function resolveReactComponent(el: HTMLElement): ComponentSourceInfo | undefined
76
76
 
77
77
  // Walk up fiber return tree to discover composite components and hierarchy
78
78
  let curr = hostFiber;
79
- while (curr) {
79
+ let depth = 0;
80
+ while (curr && depth < 50) {
81
+ depth++;
80
82
  const type = curr.type;
81
83
  const name = getReactComponentName(type);
82
84
 
@@ -146,7 +148,9 @@ function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
146
148
  const hierarchy: string[] = [];
147
149
 
148
150
  let curr = vueParent;
149
- while (curr) {
151
+ let depth = 0;
152
+ while (curr && depth < 50) {
153
+ depth++;
150
154
  const cType = curr.type || {};
151
155
  const cName = cType.name || cType.__name || cType.displayName;
152
156
  if (cName && !hierarchy.includes(cName)) {
@@ -192,7 +196,9 @@ function resolveVueComponent(el: HTMLElement): ComponentSourceInfo | undefined {
192
196
  function resolveSvelteComponent(el: HTMLElement): ComponentSourceInfo | undefined {
193
197
  try {
194
198
  let curr: HTMLElement | null = el;
195
- while (curr) {
199
+ let depth = 0;
200
+ while (curr && depth < 50) {
201
+ depth++;
196
202
  const meta = (curr as any).__svelte_meta;
197
203
  if (meta && meta.loc) {
198
204
  return {
@@ -1,4 +1,5 @@
1
1
  import type { HelloMessage, ClientCommand, CommandResponse } from '../../../core/src/index.js';
2
+ import { safeJsonStringify } from '../../../core/src/index.js';
2
3
  import type { ClientCommandHandler } from '../commands/handler.js';
3
4
 
4
5
  export interface TransportOptions {
@@ -111,15 +112,19 @@ export class WebSocketTransport {
111
112
  }
112
113
 
113
114
  public send(payload: any): void {
114
- const raw = JSON.stringify(payload);
115
- if (this.isConnected()) {
116
- try {
117
- this.ws!.send(raw);
118
- } catch {
115
+ try {
116
+ const raw = safeJsonStringify(payload);
117
+ if (this.isConnected()) {
118
+ try {
119
+ this.ws!.send(raw);
120
+ } catch {
121
+ this.enqueue(raw);
122
+ }
123
+ } else {
119
124
  this.enqueue(raw);
120
125
  }
121
- } else {
122
- this.enqueue(raw);
126
+ } catch {
127
+ // Defensive: never let payload serialization crash caller
123
128
  }
124
129
  }
125
130
 
@@ -148,18 +153,18 @@ export class WebSocketTransport {
148
153
  private sendHello(): void {
149
154
  if (typeof window === 'undefined') return;
150
155
 
151
- const hello: HelloMessage = {
152
- type: 'hello',
153
- origin: window.location.origin,
154
- url: window.location.href,
155
- title: document.title,
156
- userAgent: navigator.userAgent,
157
- timestamp: Date.now(),
158
- projectId: this.projectId,
159
- };
160
-
161
156
  try {
162
- this.ws!.send(JSON.stringify(hello));
157
+ const hello: HelloMessage = {
158
+ type: 'hello',
159
+ origin: window.location.origin,
160
+ url: window.location.href,
161
+ title: document.title,
162
+ userAgent: navigator.userAgent,
163
+ timestamp: Date.now(),
164
+ projectId: this.projectId,
165
+ };
166
+
167
+ this.ws!.send(safeJsonStringify(hello));
163
168
  } catch {
164
169
  // Defensive
165
170
  }
@@ -7,3 +7,4 @@ export * from './types/notes.js';
7
7
  export * from './fingerprint.js';
8
8
  export * from './redaction.js';
9
9
  export * from './selector.js';
10
+ export * from './safety.js';