collabmd 0.1.20 → 0.1.23

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 (120) hide show
  1. package/README.md +98 -9
  2. package/bin/collabmd.js +3 -2
  3. package/docker-compose.yml +13 -1
  4. package/package.json +2 -1
  5. package/public/assets/css/style.css +1 -1
  6. package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
  7. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  8. package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
  9. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  10. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  11. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +503 -182
  14. package/public/assets/js/preview-render-worker.js +15 -15
  15. package/public/index.html +104 -84
  16. package/src/client/application/app-shell/git-feature.js +304 -27
  17. package/src/client/application/app-shell/ui-feature.js +69 -7
  18. package/src/client/application/app-shell/workspace-feature.js +46 -0
  19. package/src/client/application/app-shell-elements.js +2 -0
  20. package/src/client/application/mermaid-preview-hydrator.js +1 -1
  21. package/src/client/application/plantuml-preview-hydrator.js +1 -1
  22. package/src/client/application/workspace-coordinator.js +147 -18
  23. package/src/client/application/workspace-preview-controller.js +34 -0
  24. package/src/client/application/workspace-route-controller.js +104 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +107 -0
  26. package/src/client/domain/runtime-paths.js +5 -0
  27. package/src/client/domain/vault-utils.js +2 -2
  28. package/src/client/excalidraw-editor.js +52 -3
  29. package/src/client/infrastructure/auth-client.js +219 -52
  30. package/src/client/infrastructure/browser-navigation-port.js +20 -0
  31. package/src/client/infrastructure/editor-collaboration-client.js +4 -0
  32. package/src/client/infrastructure/editor-session.js +51 -7
  33. package/src/client/infrastructure/editor-view-adapter.js +297 -53
  34. package/src/client/infrastructure/runtime-config.js +66 -0
  35. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  36. package/src/client/presentation/comment-ui-controller.js +274 -31
  37. package/src/client/presentation/comments-panel.js +2 -2
  38. package/src/client/presentation/excalidraw-embed-controller.js +60 -2
  39. package/src/client/presentation/file-explorer-controller.js +15 -3
  40. package/src/client/presentation/file-explorer-view.js +152 -11
  41. package/src/client/presentation/file-history-view-controller.js +806 -0
  42. package/src/client/presentation/git-diff-view-controller.js +718 -121
  43. package/src/client/presentation/git-panel-controller.js +458 -66
  44. package/src/client/presentation/layout-controller.js +1 -1
  45. package/src/client/styles/components/actions.css +89 -0
  46. package/src/client/styles/components/feedback.css +43 -0
  47. package/src/client/styles/components/navigation.css +68 -0
  48. package/src/client/styles/components/scrollbars.css +104 -0
  49. package/src/client/styles/components/states.css +289 -0
  50. package/src/client/styles/components/surfaces.css +155 -0
  51. package/src/client/styles/features/backlinks.css +213 -0
  52. package/src/client/styles/features/collaboration-chat.css +228 -0
  53. package/src/client/styles/features/collaboration-dialogs.css +154 -0
  54. package/src/client/styles/features/collaboration-presence.css +103 -0
  55. package/src/client/styles/features/collaboration-ui.css +3 -0
  56. package/src/client/styles/features/comment-card.css +236 -0
  57. package/src/client/styles/features/comment-markdown.css +127 -0
  58. package/src/client/styles/features/comment-overlays.css +60 -0
  59. package/src/client/styles/features/comments-drawer.css +120 -0
  60. package/src/client/styles/features/comments.css +4 -0
  61. package/src/client/styles/features/diagram-preview.css +227 -0
  62. package/src/client/styles/features/diff-commit.css +268 -0
  63. package/src/client/styles/features/diff-lines.css +160 -0
  64. package/src/client/styles/features/diff-toolbar.css +98 -0
  65. package/src/client/styles/features/diff.css +3 -0
  66. package/src/client/styles/features/embedded-preview.css +218 -0
  67. package/src/client/styles/features/git-branch.css +155 -0
  68. package/src/client/styles/features/git-files.css +105 -0
  69. package/src/client/styles/features/git-history.css +339 -0
  70. package/src/client/styles/features/git.css +3 -0
  71. package/src/client/styles/features/media-lightbox.css +157 -0
  72. package/src/client/styles/features/outline.css +115 -0
  73. package/src/client/styles/features/preview-content.css +3 -0
  74. package/src/client/styles/features/preview-markdown.css +201 -0
  75. package/src/client/styles/features/preview-sidebars.css +2 -0
  76. package/src/client/styles/features/quick-switcher.css +167 -0
  77. package/src/client/styles/foundation/themes.css +347 -0
  78. package/src/client/styles/foundation/tokens.css +66 -0
  79. package/src/client/styles/foundation/utilities.css +36 -0
  80. package/src/client/styles/layout/editor-page.css +203 -0
  81. package/src/client/styles/layout/overlays.css +132 -0
  82. package/src/client/styles/layout/responsive.css +251 -0
  83. package/src/client/styles/layout/shell.css +314 -0
  84. package/src/client/styles/layout/sidebar.css +176 -0
  85. package/src/client/styles/layout/view-modes.css +92 -0
  86. package/src/client/styles/overrides/codemirror.css +53 -0
  87. package/src/client/styles/overrides/highlightjs.css +7 -0
  88. package/src/client/styles/overrides/vendor.css +2 -0
  89. package/src/client/styles/primitives/controls.css +276 -0
  90. package/src/client/styles/style.css +25 -4901
  91. package/src/domain/wiki-link-resolver.js +16 -5
  92. package/src/domain/workspace-change.js +68 -0
  93. package/src/domain/workspace-room.js +3 -0
  94. package/src/server/auth/create-auth-service.js +373 -15
  95. package/src/server/auth/session-cookie.js +30 -4
  96. package/src/server/config/env.js +121 -5
  97. package/src/server/create-app-server.js +49 -10
  98. package/src/server/domain/backlink-index.js +94 -1
  99. package/src/server/domain/collaboration/collaboration-room.js +281 -69
  100. package/src/server/domain/collaboration/room-registry.js +64 -10
  101. package/src/server/index.js +3 -1
  102. package/src/server/infrastructure/git/command-runner.js +2 -1
  103. package/src/server/infrastructure/git/git-service.js +48 -2
  104. package/src/server/infrastructure/git/history-service.js +683 -0
  105. package/src/server/infrastructure/git/parsers.js +30 -0
  106. package/src/server/infrastructure/git/responses.js +85 -19
  107. package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
  108. package/src/server/infrastructure/http/create-git-api-command-handler.js +55 -43
  109. package/src/server/infrastructure/http/create-git-api-handler.js +4 -0
  110. package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
  111. package/src/server/infrastructure/http/create-request-handler.js +22 -0
  112. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  113. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  114. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  115. package/src/server/infrastructure/http/http-response.js +65 -28
  116. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  117. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  118. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  119. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  120. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
@@ -9,6 +9,10 @@ const COMMENT_CARD_WIDTH = 520;
9
9
  const COMMENT_SELECTION_REVEAL_DELAY_MS = 150;
10
10
  const COMMENT_SELECTION_CHIP_GAP = 12;
11
11
  const COMMENT_CONTROL_SLOT_HEIGHT = 36;
12
+ const COMMENT_PREVIEW_BADGE_MIN_WIDTH = 28;
13
+ const COMMENT_PREVIEW_RAIL_SLOT_HEIGHT = 30;
14
+ const COMMENT_PREVIEW_RAIL_MIN_WIDTH = 400;
15
+ const COMMENT_PREVIEW_RAIL_BREAKPOINT = 769;
12
16
  const COMMENT_REACTION_PRESET_EMOJIS = Object.freeze(['👍', '❤️', '🎉', '👀', '🚀']);
13
17
  const COMMENT_REACTION_MORE_EMOJIS = Object.freeze(['😂', '🔥', '✅', '🙏', '💡', '🤔', '👏', '😄', '🎯', '🙌']);
14
18
 
@@ -248,6 +252,25 @@ function createRectFromRects(rects = []) {
248
252
  };
249
253
  }
250
254
 
255
+ function pointIntersectsRect(x, y, rect) {
256
+ if (!rect) {
257
+ return false;
258
+ }
259
+
260
+ return x >= rect.left
261
+ && x <= rect.right
262
+ && y >= rect.top
263
+ && y <= rect.bottom;
264
+ }
265
+
266
+ function normalizeGroupKeys(keys = []) {
267
+ return [...new Set(keys.filter(Boolean))].sort();
268
+ }
269
+
270
+ function serializeGroupKeys(keys = []) {
271
+ return normalizeGroupKeys(keys).join(' ');
272
+ }
273
+
251
274
  function createCommentMarkerContent(count) {
252
275
  const fragment = document.createDocumentFragment();
253
276
 
@@ -315,6 +338,12 @@ export class CommentUiController {
315
338
  this.pointerSelecting = false;
316
339
  this.session = null;
317
340
  this.activeCard = null;
341
+ this.hoveredEditorGroupKeys = [];
342
+ this.hoveredEditorGroupKeysSignature = '';
343
+ this.hoveredPreviewGroupKeys = [];
344
+ this.hoveredPreviewGroupKeysSignature = '';
345
+ this.previewHoverRegions = [];
346
+ this.lastPreviewPointerPosition = null;
318
347
  this.editorLayer = null;
319
348
  this.previewLayer = null;
320
349
  this.previewHighlightLayer = null;
@@ -331,6 +360,20 @@ export class CommentUiController {
331
360
  this.handleEditorScroll = () => this.scheduleLayoutRefresh();
332
361
  this.handlePreviewScroll = () => this.scheduleLayoutRefresh();
333
362
  this.handleWindowResize = () => this.scheduleLayoutRefresh();
363
+ this.handlePreviewPointerMove = (event) => {
364
+ this.lastPreviewPointerPosition = { x: event.clientX, y: event.clientY };
365
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysAtPoint(event.clientX, event.clientY));
366
+ };
367
+ this.handlePreviewPointerLeave = () => {
368
+ this.lastPreviewPointerPosition = null;
369
+ this.updateHoveredPreviewGroups([]);
370
+ };
371
+ this.handlePreviewFocusIn = (event) => {
372
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.target));
373
+ };
374
+ this.handlePreviewFocusOut = (event) => {
375
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.relatedTarget));
376
+ };
334
377
  this.handleCommentSelectionButtonPointerDown = (event) => {
335
378
  event.preventDefault();
336
379
  };
@@ -436,6 +479,10 @@ export class CommentUiController {
436
479
  this.setDrawerOpen(!this.drawerOpen);
437
480
  });
438
481
  this.previewContainer?.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
482
+ this.previewElement?.addEventListener('pointermove', this.handlePreviewPointerMove, { passive: true });
483
+ this.previewElement?.addEventListener('pointerleave', this.handlePreviewPointerLeave);
484
+ this.previewElement?.addEventListener('focusin', this.handlePreviewFocusIn);
485
+ this.previewElement?.addEventListener('focusout', this.handlePreviewFocusOut);
439
486
  this.editorContainer?.addEventListener('pointerdown', this.handleEditorPointerDown);
440
487
  this.editorContainer?.addEventListener('focusout', this.handleEditorFocusOut);
441
488
  window.addEventListener('resize', this.handleWindowResize);
@@ -452,6 +499,10 @@ export class CommentUiController {
452
499
  }
453
500
  this.attachSession(null);
454
501
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
502
+ this.previewElement?.removeEventListener('pointermove', this.handlePreviewPointerMove);
503
+ this.previewElement?.removeEventListener('pointerleave', this.handlePreviewPointerLeave);
504
+ this.previewElement?.removeEventListener('focusin', this.handlePreviewFocusIn);
505
+ this.previewElement?.removeEventListener('focusout', this.handlePreviewFocusOut);
455
506
  this.commentSelectionButton?.removeEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
456
507
  this.editorContainer?.removeEventListener('pointerdown', this.handleEditorPointerDown);
457
508
  this.editorContainer?.removeEventListener('focusout', this.handleEditorFocusOut);
@@ -460,6 +511,7 @@ export class CommentUiController {
460
511
  document.removeEventListener('pointercancel', this.handleDocumentPointerUp);
461
512
  document.removeEventListener('pointerdown', this.handleDocumentPointerDown);
462
513
  document.removeEventListener('keydown', this.handleDocumentKeyDown);
514
+ this.previewHoverRegions = [];
463
515
  this.cardRoot?.remove();
464
516
  this.editorLayer?.remove();
465
517
  this.previewLayer?.remove();
@@ -493,6 +545,10 @@ export class CommentUiController {
493
545
  this.clearSelectionRevealTimer();
494
546
  this.pointerSelecting = false;
495
547
  this.activeCard = null;
548
+ this.updateHoveredEditorGroups([]);
549
+ this.updateHoveredPreviewGroups([]);
550
+ this.previewHoverRegions = [];
551
+ this.lastPreviewPointerPosition = null;
496
552
  this.reactionPicker = null;
497
553
  }
498
554
  if (!this.supported) {
@@ -504,6 +560,10 @@ export class CommentUiController {
504
560
  this.committedSelectionAnchor = null;
505
561
  this.clearSelectionRevealTimer();
506
562
  this.pointerSelecting = false;
563
+ this.updateHoveredEditorGroups([]);
564
+ this.updateHoveredPreviewGroups([]);
565
+ this.previewHoverRegions = [];
566
+ this.lastPreviewPointerPosition = null;
507
567
  this.reactionPicker = null;
508
568
  }
509
569
  this.render();
@@ -611,6 +671,51 @@ export class CommentUiController {
611
671
  this.repositionActiveCard();
612
672
  }
613
673
 
674
+ syncPreviewRailLayout(maxBubbleWidth = 0) {
675
+ if (!this.previewElement) {
676
+ return false;
677
+ }
678
+
679
+ const shouldShowRail = this.supported && maxBubbleWidth > 0;
680
+ const nextReserved = 0;
681
+ const nextOffset = 0;
682
+ let reserved = nextReserved;
683
+ let offset = nextOffset;
684
+
685
+ if (shouldShowRail) {
686
+ const previewStyle = getComputedStyle(this.previewElement);
687
+ const currentReserved = Number.parseFloat(
688
+ previewStyle.getPropertyValue('--preview-comment-rail-reserved'),
689
+ ) || 0;
690
+ const currentPaddingRight = Number.parseFloat(previewStyle.paddingRight) || 0;
691
+ const railInset = Number.parseFloat(
692
+ previewStyle.getPropertyValue('--preview-comment-rail-inset'),
693
+ ) || 0;
694
+ const basePaddingRight = Math.max(currentPaddingRight - currentReserved, 0);
695
+ const requiredRail = Math.max(maxBubbleWidth + railInset - basePaddingRight, 0);
696
+ const previewContainerRect = this.previewContainer?.getBoundingClientRect();
697
+ const previewRect = this.previewElement.getBoundingClientRect();
698
+ const availableRightGutter = previewContainerRect
699
+ ? Math.max(previewContainerRect.right - previewRect.right, 0)
700
+ : 0;
701
+
702
+ offset = Math.min(availableRightGutter, requiredRail);
703
+ reserved = Math.max(requiredRail - offset, 0);
704
+ }
705
+
706
+ const nextReservedValue = `${Math.ceil(reserved)}px`;
707
+ const nextOffsetValue = `${Math.floor(offset)}px`;
708
+ const didChange = this.previewElement.style.getPropertyValue('--preview-comment-rail-reserved') !== nextReservedValue
709
+ || this.previewElement.style.getPropertyValue('--preview-comment-rail-offset') !== nextOffsetValue;
710
+
711
+ if (didChange) {
712
+ this.previewElement.style.setProperty('--preview-comment-rail-reserved', nextReservedValue);
713
+ this.previewElement.style.setProperty('--preview-comment-rail-offset', nextOffsetValue);
714
+ }
715
+
716
+ return didChange;
717
+ }
718
+
614
719
  scheduleLayoutRefresh() {
615
720
  if (this.layoutFrame) {
616
721
  return;
@@ -641,7 +746,7 @@ export class CommentUiController {
641
746
  this.commentsToggleButton.classList.toggle('active', this.drawerOpen);
642
747
  this.commentsToggleButton.setAttribute('aria-expanded', String(this.drawerOpen));
643
748
  const label = totalCount > 0 ? `Comments ${totalCount}` : 'Comments';
644
- const labelElement = this.commentsToggleButton.querySelector('.pane-header-btn-label');
749
+ const labelElement = this.commentsToggleButton.querySelector('.ui-action-label');
645
750
  if (labelElement) {
646
751
  labelElement.textContent = label;
647
752
  } else {
@@ -667,7 +772,7 @@ export class CommentUiController {
667
772
  groups.forEach((group) => {
668
773
  const button = document.createElement('button');
669
774
  button.type = 'button';
670
- button.className = 'comments-drawer-item';
775
+ button.className = 'ui-record-surface comments-drawer-item';
671
776
  button.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
672
777
  button.addEventListener('pointerdown', (event) => {
673
778
  event.preventDefault();
@@ -681,14 +786,14 @@ export class CommentUiController {
681
786
  });
682
787
 
683
788
  const header = document.createElement('div');
684
- header.className = 'comments-drawer-item-header';
789
+ header.className = 'ui-record-header comments-drawer-item-header';
685
790
 
686
791
  const title = document.createElement('span');
687
792
  title.className = 'comments-drawer-item-title';
688
793
  title.textContent = formatAnchorLabel(group.anchor);
689
794
 
690
795
  const count = document.createElement('span');
691
- count.className = 'comments-drawer-item-count';
796
+ count.className = 'ui-pill-badge ui-pill-badge--count ui-pill-badge--accent comments-drawer-item-count';
692
797
  count.textContent = String(group.threads.length);
693
798
 
694
799
  header.append(title, count);
@@ -704,7 +809,7 @@ export class CommentUiController {
704
809
  );
705
810
 
706
811
  const footer = document.createElement('div');
707
- footer.className = 'comments-drawer-item-footer';
812
+ footer.className = 'ui-record-meta comments-drawer-item-footer';
708
813
  const countLabel = document.createElement('span');
709
814
  countLabel.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
710
815
 
@@ -757,11 +862,19 @@ export class CommentUiController {
757
862
  }
758
863
 
759
864
  const relativeRect = toRelativeRect(rect, containerRect);
865
+ if (relativeRect.bottom < 0 || relativeRect.top > containerRect.height) {
866
+ return;
867
+ }
868
+
760
869
  const button = document.createElement('button');
761
870
  button.type = 'button';
762
- button.className = 'comment-editor-badge';
871
+ button.className = 'ui-state-marker ui-state-marker--comment comment-editor-badge';
763
872
  button.dataset.count = String(group.threads.length);
764
- button.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
873
+ const isActive = this.activeCard?.groupKey === group.key;
874
+ const isHovered = this.hoveredEditorGroupKeys.includes(group.key);
875
+ button.classList.toggle('is-active', isActive);
876
+ button.classList.toggle('is-hovered', isHovered);
877
+ button.classList.toggle('is-passive', !isActive && !isHovered);
765
878
  button.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
766
879
  button.appendChild(createCommentMarkerContent(group.threads.length));
767
880
  const top = Math.max(relativeRect.top, 8);
@@ -771,6 +884,18 @@ export class CommentUiController {
771
884
  button.addEventListener('pointerdown', (event) => {
772
885
  event.preventDefault();
773
886
  });
887
+ button.addEventListener('pointerenter', () => {
888
+ this.updateHoveredEditorGroups([group.key]);
889
+ });
890
+ button.addEventListener('pointerleave', () => {
891
+ this.updateHoveredEditorGroups([]);
892
+ });
893
+ button.addEventListener('focusin', () => {
894
+ this.updateHoveredEditorGroups([group.key]);
895
+ });
896
+ button.addEventListener('focusout', () => {
897
+ this.updateHoveredEditorGroups([]);
898
+ });
774
899
  button.addEventListener('click', () => {
775
900
  this.openThreadGroup(group, {
776
901
  anchor: group.anchor,
@@ -807,7 +932,7 @@ export class CommentUiController {
807
932
  }
808
933
  const button = document.createElement('button');
809
934
  button.type = 'button';
810
- button.className = 'comment-selection-chip';
935
+ button.className = 'ui-selection-pill ui-selection-pill--comment comment-selection-chip';
811
936
  button.textContent = 'Comment';
812
937
  button.style.top = `${chipTop}px`;
813
938
  button.style.right = `${COMMENT_SELECTION_CHIP_GAP}px`;
@@ -840,20 +965,38 @@ export class CommentUiController {
840
965
  this.previewHighlightLayer?.replaceChildren();
841
966
 
842
967
  if (!this.supported || !this.previewElement) {
968
+ this.previewHoverRegions = [];
969
+ this.syncPreviewRailLayout(0);
843
970
  return;
844
971
  }
845
972
 
846
973
  const previewRect = this.previewElement.getBoundingClientRect();
847
974
  const groups = this.getThreadGroups();
975
+ const occupiedTops = [];
976
+ const hoverRegions = [];
977
+ const showPassiveMarkers = this.shouldRenderPassivePreviewMarkers();
978
+ const previewBubbles = [];
848
979
  groups.forEach((group) => {
849
980
  const target = this.resolvePreviewTarget(group.anchor);
850
981
  if (!target?.bubbleRect) {
851
982
  return;
852
983
  }
853
984
 
985
+ hoverRegions.push({
986
+ key: group.key,
987
+ rects: target.hoverRects?.length > 0 ? target.hoverRects : [target.bubbleRect],
988
+ });
989
+
990
+ const isActive = this.activeCard?.groupKey === group.key;
991
+ const isHovered = this.hoveredPreviewGroupKeys.includes(group.key);
992
+ const isEmphasized = isActive || isHovered;
993
+
854
994
  target.highlightRects?.forEach((rect) => {
855
995
  const highlight = document.createElement('div');
856
996
  highlight.className = 'comment-preview-highlight';
997
+ highlight.classList.toggle('is-active', isActive);
998
+ highlight.classList.toggle('is-hovered', isHovered);
999
+ highlight.classList.toggle('is-passive', !isEmphasized);
857
1000
  highlight.style.left = `${rect.left - previewRect.left}px`;
858
1001
  highlight.style.top = `${rect.top - previewRect.top}px`;
859
1002
  highlight.style.width = `${rect.width}px`;
@@ -861,14 +1004,32 @@ export class CommentUiController {
861
1004
  this.previewHighlightLayer?.appendChild(highlight);
862
1005
  });
863
1006
 
1007
+ if (!showPassiveMarkers && !isEmphasized) {
1008
+ return;
1009
+ }
1010
+
864
1011
  const bubble = document.createElement('button');
865
1012
  bubble.type = 'button';
866
- bubble.className = 'comment-preview-badge';
867
- bubble.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
1013
+ bubble.className = 'ui-state-marker ui-state-marker--comment comment-preview-badge';
1014
+ bubble.dataset.commentPreviewGroupKeys = group.key;
1015
+ bubble.classList.toggle('is-active', isActive);
1016
+ bubble.classList.toggle('is-hovered', isHovered);
1017
+ bubble.classList.toggle('is-passive', !isEmphasized);
868
1018
  bubble.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
869
1019
  bubble.appendChild(createCommentMarkerContent(group.threads.length));
870
- bubble.style.left = `${clamp(target.bubbleRect.right - previewRect.left + 6, 6, this.previewElement.clientWidth - 34)}px`;
871
- bubble.style.top = `${Math.max(target.bubbleRect.top - previewRect.top, 6)}px`;
1020
+ let bubbleTop = clamp(
1021
+ target.bubbleRect.top - previewRect.top,
1022
+ 6,
1023
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
1024
+ );
1025
+ while (occupiedTops.some((top) => Math.abs(top - bubbleTop) < (COMMENT_PREVIEW_RAIL_SLOT_HEIGHT - 4))) {
1026
+ bubbleTop = clamp(
1027
+ bubbleTop + COMMENT_PREVIEW_RAIL_SLOT_HEIGHT,
1028
+ 6,
1029
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
1030
+ );
1031
+ }
1032
+ bubble.style.top = `${bubbleTop}px`;
872
1033
  bubble.title = `${group.threads.length} comment${group.threads.length === 1 ? '' : 's'}`;
873
1034
  bubble.addEventListener('pointerdown', (event) => {
874
1035
  event.preventDefault();
@@ -877,11 +1038,27 @@ export class CommentUiController {
877
1038
  this.openThreadGroup(group, {
878
1039
  anchor: group.anchor,
879
1040
  origin: 'preview',
880
- sourceRect: target.bubbleRect,
1041
+ sourceRect: bubble.getBoundingClientRect(),
881
1042
  });
882
1043
  });
883
1044
  this.previewLayer?.appendChild(bubble);
1045
+ occupiedTops.push(bubbleTop);
1046
+ previewBubbles.push(bubble);
884
1047
  });
1048
+
1049
+ this.previewHoverRegions = hoverRegions;
1050
+ const maxBubbleWidth = previewBubbles.reduce(
1051
+ (maxWidth, bubble) => Math.max(maxWidth, bubble.offsetWidth || COMMENT_PREVIEW_BADGE_MIN_WIDTH),
1052
+ 0,
1053
+ );
1054
+ if (this.syncPreviewRailLayout(maxBubbleWidth)) {
1055
+ this.scheduleLayoutRefresh();
1056
+ }
1057
+ if (this.lastPreviewPointerPosition) {
1058
+ this.updateHoveredPreviewGroups(
1059
+ this.getPreviewGroupKeysAtPoint(this.lastPreviewPointerPosition.x, this.lastPreviewPointerPosition.y),
1060
+ );
1061
+ }
885
1062
  }
886
1063
 
887
1064
  resolvePreviewTarget(anchor) {
@@ -895,6 +1072,7 @@ export class CommentUiController {
895
1072
  return {
896
1073
  bubbleRect: diagramShell.getBoundingClientRect(),
897
1074
  highlightRects: [],
1075
+ hoverRects: [diagramShell.getBoundingClientRect()],
898
1076
  };
899
1077
  }
900
1078
 
@@ -911,6 +1089,7 @@ export class CommentUiController {
911
1089
  return {
912
1090
  bubbleRect,
913
1091
  highlightRects: rects,
1092
+ hoverRects: rects,
914
1093
  };
915
1094
  }
916
1095
  }
@@ -923,6 +1102,7 @@ export class CommentUiController {
923
1102
  return {
924
1103
  bubbleRect: fallback.getBoundingClientRect(),
925
1104
  highlightRects: [],
1105
+ hoverRects: [fallback.getBoundingClientRect()],
926
1106
  };
927
1107
  }
928
1108
 
@@ -1056,7 +1236,7 @@ export class CommentUiController {
1056
1236
  });
1057
1237
 
1058
1238
  const header = document.createElement('div');
1059
- header.className = 'comment-card-header';
1239
+ header.className = 'ui-record-header comment-card-header';
1060
1240
 
1061
1241
  const titleWrap = document.createElement('div');
1062
1242
  titleWrap.className = 'comment-card-title-wrap';
@@ -1073,7 +1253,7 @@ export class CommentUiController {
1073
1253
 
1074
1254
  const closeButton = document.createElement('button');
1075
1255
  closeButton.type = 'button';
1076
- closeButton.className = 'comment-card-close';
1256
+ closeButton.className = 'ui-button ui-button--compact btn btn-ghost ui-pill-button ui-action-pill comment-card-close';
1077
1257
  closeButton.setAttribute('aria-label', 'Close comments');
1078
1258
  closeButton.textContent = 'Close';
1079
1259
  closeButton.addEventListener('click', () => this.closeCard());
@@ -1115,6 +1295,69 @@ export class CommentUiController {
1115
1295
  this.scheduleLayoutRefresh();
1116
1296
  }
1117
1297
 
1298
+ getPreviewGroupKeysForTarget(target) {
1299
+ if (!(target instanceof Node)) {
1300
+ return [];
1301
+ }
1302
+
1303
+ const keyCarrier = target.closest?.('[data-comment-preview-group-keys]');
1304
+ return serializeGroupKeys(
1305
+ String(keyCarrier?.dataset?.commentPreviewGroupKeys ?? '')
1306
+ .split(/\s+/)
1307
+ .filter(Boolean),
1308
+ ).split(' ').filter(Boolean);
1309
+ }
1310
+
1311
+ getPreviewGroupKeysAtPoint(x, y) {
1312
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1313
+ return [];
1314
+ }
1315
+
1316
+ const targetAtPoint = document.elementFromPoint(x, y);
1317
+ const targetKeys = this.getPreviewGroupKeysForTarget(targetAtPoint);
1318
+ if (targetKeys.length > 0) {
1319
+ return targetKeys;
1320
+ }
1321
+
1322
+ const matchingKeys = this.previewHoverRegions
1323
+ .filter((region) => region.rects.some((rect) => pointIntersectsRect(x, y, rect)))
1324
+ .map((region) => region.key);
1325
+ return normalizeGroupKeys(matchingKeys);
1326
+ }
1327
+
1328
+ updateHoveredPreviewGroups(nextKeys = []) {
1329
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1330
+ const signature = normalizedKeys.join(' ');
1331
+ if (signature === this.hoveredPreviewGroupKeysSignature) {
1332
+ return;
1333
+ }
1334
+
1335
+ this.hoveredPreviewGroupKeys = normalizedKeys;
1336
+ this.hoveredPreviewGroupKeysSignature = signature;
1337
+ this.scheduleLayoutRefresh();
1338
+ }
1339
+
1340
+ updateHoveredEditorGroups(nextKeys = []) {
1341
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1342
+ const signature = normalizedKeys.join(' ');
1343
+ if (signature === this.hoveredEditorGroupKeysSignature) {
1344
+ return;
1345
+ }
1346
+
1347
+ this.hoveredEditorGroupKeys = normalizedKeys;
1348
+ this.hoveredEditorGroupKeysSignature = signature;
1349
+ this.scheduleLayoutRefresh();
1350
+ }
1351
+
1352
+ syncHoveredPreviewGroupsFromTarget(target) {
1353
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(target));
1354
+ }
1355
+
1356
+ shouldRenderPassivePreviewMarkers() {
1357
+ const previewWidth = this.previewContainer?.clientWidth ?? this.previewElement?.clientWidth ?? 0;
1358
+ return window.innerWidth >= COMMENT_PREVIEW_RAIL_BREAKPOINT && previewWidth >= COMMENT_PREVIEW_RAIL_MIN_WIDTH;
1359
+ }
1360
+
1118
1361
  updateReactionPickerPosition(card) {
1119
1362
  const bounds = getReactionPickerBounds(card);
1120
1363
  if (!bounds) {
@@ -1166,17 +1409,17 @@ export class CommentUiController {
1166
1409
  textarea.placeholder = 'Add context, feedback, or a question...';
1167
1410
 
1168
1411
  const actions = document.createElement('div');
1169
- actions.className = 'comment-card-actions';
1412
+ actions.className = 'ui-record-actions comment-card-actions';
1170
1413
 
1171
1414
  const cancel = document.createElement('button');
1172
1415
  cancel.type = 'button';
1173
- cancel.className = 'btn btn-secondary';
1416
+ cancel.className = 'ui-button btn btn-secondary';
1174
1417
  cancel.textContent = 'Cancel';
1175
1418
  cancel.addEventListener('click', () => this.closeCard());
1176
1419
 
1177
1420
  const submit = document.createElement('button');
1178
1421
  submit.type = 'submit';
1179
- submit.className = 'btn btn-primary';
1422
+ submit.className = 'ui-button btn btn-primary';
1180
1423
  submit.textContent = 'Post comment';
1181
1424
 
1182
1425
  actions.append(cancel, submit);
@@ -1205,7 +1448,7 @@ export class CommentUiController {
1205
1448
  article.className = 'comment-thread-card';
1206
1449
 
1207
1450
  const header = document.createElement('div');
1208
- header.className = 'comment-thread-card-header';
1451
+ header.className = 'ui-record-header comment-thread-card-header';
1209
1452
 
1210
1453
  const heading = document.createElement('div');
1211
1454
  heading.className = 'comment-thread-card-heading';
@@ -1219,17 +1462,17 @@ export class CommentUiController {
1219
1462
  time.textContent = this.formatTimestamp(thread.createdAt);
1220
1463
 
1221
1464
  const actions = document.createElement('div');
1222
- actions.className = 'comment-thread-card-actions';
1465
+ actions.className = 'ui-record-actions comment-thread-card-actions';
1223
1466
 
1224
1467
  const jump = document.createElement('button');
1225
1468
  jump.type = 'button';
1226
- jump.className = 'comment-thread-card-action';
1469
+ jump.className = 'ui-button ui-button--compact btn btn-ghost ui-pill-button ui-action-pill comment-thread-card-action';
1227
1470
  jump.textContent = 'Jump';
1228
1471
  jump.addEventListener('click', () => this.onNavigateToLine?.(thread.anchor?.startLine ?? 1));
1229
1472
 
1230
1473
  const reply = document.createElement('button');
1231
1474
  reply.type = 'button';
1232
- reply.className = 'comment-thread-card-action';
1475
+ reply.className = 'ui-button ui-button--compact btn btn-ghost ui-pill-button ui-action-pill comment-thread-card-action';
1233
1476
  const isReplying = this.activeCard?.replyThreadId === thread.id;
1234
1477
  reply.classList.toggle('is-active', isReplying);
1235
1478
  reply.textContent = 'Reply';
@@ -1246,7 +1489,7 @@ export class CommentUiController {
1246
1489
 
1247
1490
  const resolve = document.createElement('button');
1248
1491
  resolve.type = 'button';
1249
- resolve.className = 'comment-thread-card-action is-danger';
1492
+ resolve.className = 'ui-button ui-button--compact btn btn-ghost ui-pill-button ui-action-pill comment-thread-card-action is-danger';
1250
1493
  resolve.textContent = 'Resolve';
1251
1494
  resolve.addEventListener('click', async () => {
1252
1495
  await this.onResolveThread?.(thread.id);
@@ -1274,7 +1517,7 @@ export class CommentUiController {
1274
1517
  container.className = 'comment-message-card';
1275
1518
 
1276
1519
  const meta = document.createElement('div');
1277
- meta.className = 'comment-message-card-meta';
1520
+ meta.className = 'ui-record-meta comment-message-card-meta';
1278
1521
 
1279
1522
  const author = document.createElement('span');
1280
1523
  author.className = 'comment-message-card-author';
@@ -1308,7 +1551,7 @@ export class CommentUiController {
1308
1551
  (message.reactions ?? []).forEach((reaction) => {
1309
1552
  const chip = document.createElement('button');
1310
1553
  chip.type = 'button';
1311
- chip.className = 'comment-reaction-chip';
1554
+ chip.className = 'ui-chip-button ui-chip-button--comment comment-reaction-chip';
1312
1555
  chip.classList.toggle('is-active', hasLocalReaction(reaction, localUserId));
1313
1556
  chip.setAttribute('aria-pressed', String(hasLocalReaction(reaction, localUserId)));
1314
1557
  chip.title = reaction.users?.map((user) => user.userName).join(', ') || reaction.emoji;
@@ -1342,7 +1585,7 @@ export class CommentUiController {
1342
1585
 
1343
1586
  const moreButton = document.createElement('button');
1344
1587
  moreButton.type = 'button';
1345
- moreButton.className = 'comment-reaction-more-trigger';
1588
+ moreButton.className = 'ui-chip-button ui-chip-button--comment comment-reaction-more-trigger';
1346
1589
  moreButton.dataset.reactionPickerToggle = 'true';
1347
1590
  moreButton.setAttribute('aria-expanded', String(
1348
1591
  isReactionPickerOpen(this.reactionPicker, thread.id, message.id),
@@ -1373,7 +1616,7 @@ export class CommentUiController {
1373
1616
  createQuickReactionButton(thread, message, emoji) {
1374
1617
  const button = document.createElement('button');
1375
1618
  button.type = 'button';
1376
- button.className = 'comment-reaction-quick-add';
1619
+ button.className = 'ui-icon-chip ui-icon-chip--comment comment-reaction-quick-add';
1377
1620
  button.textContent = emoji;
1378
1621
  button.title = `React with ${emoji}`;
1379
1622
  button.addEventListener('click', async () => {
@@ -1398,7 +1641,7 @@ export class CommentUiController {
1398
1641
  createReactionPickerButton(thread, message, emoji) {
1399
1642
  const button = document.createElement('button');
1400
1643
  button.type = 'button';
1401
- button.className = 'comment-reaction-picker-btn';
1644
+ button.className = 'ui-icon-chip ui-icon-chip--comment comment-reaction-picker-btn';
1402
1645
  button.textContent = emoji;
1403
1646
  button.title = `React with ${emoji}`;
1404
1647
  button.addEventListener('click', async () => {
@@ -1422,11 +1665,11 @@ export class CommentUiController {
1422
1665
  textarea.placeholder = 'Reply to thread...';
1423
1666
 
1424
1667
  const actions = document.createElement('div');
1425
- actions.className = 'comment-card-actions';
1668
+ actions.className = 'ui-record-actions comment-card-actions';
1426
1669
 
1427
1670
  const cancel = document.createElement('button');
1428
1671
  cancel.type = 'button';
1429
- cancel.className = 'btn btn-secondary';
1672
+ cancel.className = 'ui-button btn btn-secondary';
1430
1673
  cancel.textContent = 'Cancel';
1431
1674
  cancel.addEventListener('click', () => {
1432
1675
  this.activeCard = {
@@ -1438,7 +1681,7 @@ export class CommentUiController {
1438
1681
 
1439
1682
  const submit = document.createElement('button');
1440
1683
  submit.type = 'submit';
1441
- submit.className = 'btn btn-primary';
1684
+ submit.className = 'ui-button btn btn-primary';
1442
1685
  submit.textContent = 'Reply';
1443
1686
 
1444
1687
  actions.append(cancel, submit);
@@ -463,7 +463,7 @@ export class CommentsPanel {
463
463
 
464
464
  const cancelButton = document.createElement('button');
465
465
  cancelButton.type = 'button';
466
- cancelButton.className = 'btn btn-secondary';
466
+ cancelButton.className = 'ui-button btn btn-secondary';
467
467
  cancelButton.textContent = 'Cancel';
468
468
  cancelButton.addEventListener('click', () => {
469
469
  this.replyDraftThreadId = null;
@@ -472,7 +472,7 @@ export class CommentsPanel {
472
472
 
473
473
  const submitButton = document.createElement('button');
474
474
  submitButton.type = 'submit';
475
- submitButton.className = 'btn btn-primary';
475
+ submitButton.className = 'ui-button btn btn-primary';
476
476
  submitButton.textContent = 'Reply';
477
477
 
478
478
  actions.append(cancelButton, submitButton);