@plannotator/ui 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/HANDOFF.md +6 -4
  2. package/README.md +57 -1
  3. package/components/ActionMenu.tsx +22 -24
  4. package/components/AnnotationToolbar.tsx +3 -1
  5. package/components/ApproveDropdown.tsx +15 -18
  6. package/components/CommentPopover.tsx +9 -4
  7. package/components/DecisionControl.tsx +626 -0
  8. package/components/DocBadges.tsx +9 -6
  9. package/components/FloatingQuickLabelPicker.tsx +4 -1
  10. package/components/KeyboardShortcuts.tsx +1 -0
  11. package/components/PlanHeaderMenu.tsx +1 -1
  12. package/components/Settings.tsx +67 -6
  13. package/components/StickyHeaderLane.tsx +16 -40
  14. package/components/ToolbarButtons.tsx +31 -9
  15. package/components/Viewer.tsx +277 -95
  16. package/components/VimTargetReticle.tsx +1 -1
  17. package/components/blocks/AlertBlock.tsx +51 -4
  18. package/components/compactHeaderLayout.ts +52 -0
  19. package/config/reviewView.ts +1 -0
  20. package/config/settings.ts +48 -3
  21. package/configure.ts +9 -0
  22. package/hooks/useAnnotationHighlighter.ts +31 -19
  23. package/hooks/useDismissablePopover.ts +65 -0
  24. package/hooks/useVimDocumentFocus.ts +6 -0
  25. package/package.json +22 -22
  26. package/shortcuts/decisionControl.shortcuts.ts +31 -0
  27. package/shortcuts/index.ts +1 -0
  28. package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
  29. package/styles.css +1 -1
  30. package/theme.css +11 -0
  31. package/utils/alertTitle.ts +69 -0
  32. package/utils/decisionSpec.ts +430 -0
  33. package/utils/platform.ts +16 -0
  34. package/utils/vimScroll.ts +1 -1
  35. package/sprite_package_additional/index.html +0 -34
  36. package/sprite_package_new/index.html +0 -34
  37. package/sprite_package_pulluphang/index.html +0 -34
@@ -1,3 +1,4 @@
1
+ import { generateId } from '../utils/generateId';
1
2
  import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
3
  import { createPortal } from 'react-dom';
3
4
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
@@ -44,7 +45,7 @@ import { MermaidBlock } from './MermaidBlock';
44
45
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
45
46
  import { getIdentity } from '../utils/identity';
46
47
  import { type QuickLabel } from '../utils/quickLabels';
47
- import { DocBadges, type LinkedDocBadgeInfo } from './DocBadges';
48
+ import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
48
49
  import { PinpointOverlay } from './PinpointOverlay';
49
50
  import { usePinpoint } from '../hooks/usePinpoint';
50
51
  import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
@@ -58,8 +59,24 @@ import {
58
59
  } from '../hooks/useScrollViewport';
59
60
  import { decodeAnchorHash } from '../utils/anchors';
60
61
  import { VimModeOverlay } from './VimModeOverlay';
62
+ import { AnnotationToolstrip } from './AnnotationToolstrip';
63
+ import {
64
+ resolveCompactHeaderGeometry,
65
+ snapCompactHeaderWidth,
66
+ } from './compactHeaderLayout';
67
+
68
+ /** Typed controls required for Viewer's opt-in, in-flow annotation header. */
69
+ export interface ViewerAnnotationHeaderConfig {
70
+ /** Persist and apply a Select or Pinpoint input-method change. */
71
+ readonly onInputMethodChange: (method: InputMethod) => void;
72
+ /** Persist and apply an annotation-mode change. */
73
+ readonly onModeChange: (mode: EditorMode) => void;
74
+ /** Omit Quick Label without changing or coercing the current mode. */
75
+ readonly hideQuickLabel?: boolean;
76
+ }
61
77
 
62
- interface ViewerProps {
78
+ /** Public properties for the Markdown document Viewer. */
79
+ export interface ViewerProps {
63
80
  blocks: Block[];
64
81
  markdown: string;
65
82
  frontmatter?: Frontmatter | null;
@@ -75,6 +92,14 @@ interface ViewerProps {
75
92
  onRemoveGlobalAttachment?: (path: string) => void;
76
93
  repoInfo?: { display: string; branch?: string; host?: string } | null;
77
94
  stickyActions?: boolean;
95
+ /**
96
+ * Render compact annotation controls and the existing document actions in
97
+ * one measured, in-flow header owned by Viewer. The header reserves its
98
+ * responsive height before document content and follows `stickyActions`.
99
+ * Omit this prop to retain the legacy floating action bar exactly. Read-only
100
+ * viewers suppress the annotation header rather than expose dead controls.
101
+ */
102
+ annotationHeader?: ViewerAnnotationHeaderConfig;
78
103
  /** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
79
104
  gridEnabled?: boolean;
80
105
  onOpenLinkedDoc?: (path: string) => void;
@@ -189,6 +214,118 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
189
214
  );
190
215
  };
191
216
 
217
+ interface ViewerDocumentHeaderProps {
218
+ readonly config: ViewerAnnotationHeaderConfig;
219
+ readonly inputMethod: InputMethod;
220
+ readonly mode: EditorMode;
221
+ readonly taterMode: boolean;
222
+ readonly sticky: boolean;
223
+ readonly stuck: boolean;
224
+ readonly sentinelRef: React.RefObject<HTMLDivElement | null>;
225
+ readonly badges: Omit<DocBadgesProps, 'layout'>;
226
+ readonly actions: React.ReactNode;
227
+ }
228
+
229
+ /** Viewer-owned header that keeps leading controls and trailing actions in one flow. */
230
+ const ViewerDocumentHeader: React.FC<ViewerDocumentHeaderProps> = ({
231
+ config,
232
+ inputMethod,
233
+ mode,
234
+ taterMode,
235
+ sticky,
236
+ stuck,
237
+ sentinelRef,
238
+ badges,
239
+ actions,
240
+ }) => {
241
+ const headerRef = useRef<HTMLDivElement>(null);
242
+ const actionsRef = useRef<HTMLDivElement>(null);
243
+ const [headerWidth, setHeaderWidth] = useState(0);
244
+ const [actionsWidth, setActionsWidth] = useState(0);
245
+
246
+ useEffect(() => {
247
+ const header = headerRef.current;
248
+ if (!header) return;
249
+ const observer = new ResizeObserver(([entry]) => {
250
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
251
+ setHeaderWidth((current) => current === next ? current : next);
252
+ });
253
+ observer.observe(header);
254
+ return () => observer.disconnect();
255
+ }, []);
256
+
257
+ useEffect(() => {
258
+ const actionCluster = actionsRef.current;
259
+ if (!actionCluster) return;
260
+ const observer = new ResizeObserver(([entry]) => {
261
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
262
+ setActionsWidth((current) => current === next ? current : next);
263
+ });
264
+ observer.observe(actionCluster);
265
+ return () => observer.disconnect();
266
+ }, []);
267
+
268
+ const geometry = resolveCompactHeaderGeometry({
269
+ containerWidth: headerWidth,
270
+ trailingWidth: actionsWidth,
271
+ leadingInset: 0,
272
+ });
273
+ const narrow = geometry.layout === 'narrow';
274
+ const iconOnly = geometry.layout !== 'wide';
275
+
276
+ return (
277
+ <>
278
+ {sticky && <div ref={sentinelRef} className="h-0 w-0" aria-hidden="true" />}
279
+ <div
280
+ ref={headerRef}
281
+ data-annotation-exclude
282
+ data-print-hide
283
+ data-viewer-document-header
284
+ data-header-layout={geometry.layout}
285
+ className={`annotation-exclude ${sticky ? 'sticky top-3' : 'relative'} z-40 mb-3 md:mb-4 rounded-lg transition-colors duration-150 ${
286
+ stuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''
287
+ }`}
288
+ >
289
+ <div className={`flex min-w-0 ${
290
+ narrow
291
+ ? 'flex-col items-stretch gap-2'
292
+ : 'flex-row items-start justify-between gap-4'
293
+ }`}>
294
+ <div
295
+ data-viewer-annotation-controls
296
+ className={`inline-flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 py-1 md:py-2 ${
297
+ narrow ? 'w-full' : 'flex-1'
298
+ }`}
299
+ >
300
+ <div className="flex-shrink-0">
301
+ <AnnotationToolstrip
302
+ inputMethod={inputMethod}
303
+ onInputMethodChange={config.onInputMethodChange}
304
+ mode={mode}
305
+ onModeChange={config.onModeChange}
306
+ taterMode={taterMode}
307
+ hideQuickLabel={config.hideQuickLabel}
308
+ compact
309
+ iconOnly={iconOnly}
310
+ />
311
+ </div>
312
+ <DocBadges layout="header" {...badges} />
313
+ </div>
314
+ <div
315
+ ref={actionsRef}
316
+ data-sticky-actions
317
+ className={`flex flex-shrink-0 items-start gap-1 rounded-lg p-1 md:gap-2 md:p-2 ${
318
+ narrow ? 'self-end' : ''
319
+ }`}
320
+ >
321
+ {actions}
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </>
326
+ );
327
+ };
328
+
192
329
  /**
193
330
  * Render and annotate a parsed Markdown document.
194
331
  *
@@ -211,6 +348,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
211
348
  onRemoveGlobalAttachment,
212
349
  repoInfo,
213
350
  stickyActions = true,
351
+ annotationHeader,
214
352
  gridEnabled = false,
215
353
  planDiffStats,
216
354
  isPlanDiffActive,
@@ -242,6 +380,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
242
380
  vimHudKeyPanelEnabled = true,
243
381
  onVimHudKeyPanelChange,
244
382
  }, ref) => {
383
+ const viewerAnnotationHeader = readOnly ? undefined : annotationHeader;
384
+ const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
245
385
  const [copied, setCopied] = useState(false);
246
386
  const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
247
387
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
@@ -271,6 +411,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
271
411
  const docBadgesRef = useRef<HTMLDivElement | null>(null);
272
412
  const [badgeClearance, setBadgeClearance] = useState(0);
273
413
  useEffect(() => {
414
+ if (hasViewerAnnotationHeader) {
415
+ setBadgeClearance(0);
416
+ return;
417
+ }
274
418
  const el = docBadgesRef.current;
275
419
  const article = containerRef.current;
276
420
  if (!el || !article) { setBadgeClearance(0); return; }
@@ -284,7 +428,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
284
428
  ro.observe(el);
285
429
  window.addEventListener('resize', measure);
286
430
  return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
287
- }, [repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
431
+ }, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
288
432
 
289
433
  // Per-doc heading slug map with dedup — computed once per blocks array so
290
434
  // anchor ids stay stable across re-renders and duplicate heading texts get
@@ -605,14 +749,18 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
605
749
  // The IntersectionObserver root must be the actual scroll element — the
606
750
  // OverlayScrollArea viewport — not the <main> host, which doesn't scroll.
607
751
  useEffect(() => {
608
- if (!stickyActions || !stickySentinelRef.current || !scrollViewport) return;
752
+ if (!stickyActions) {
753
+ setIsStuck(false);
754
+ return;
755
+ }
756
+ if (!stickySentinelRef.current || !scrollViewport) return;
609
757
  const observer = new IntersectionObserver(
610
758
  ([entry]) => setIsStuck(!entry.isIntersecting),
611
759
  { root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
612
760
  );
613
761
  observer.observe(stickySentinelRef.current);
614
762
  return () => observer.disconnect();
615
- }, [stickyActions, scrollViewport]);
763
+ }, [hasViewerAnnotationHeader, stickyActions, scrollViewport]);
616
764
 
617
765
  useEffect(() => {
618
766
  const handleHashChange = () => {
@@ -634,12 +782,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
634
782
  const target = document.getElementById(anchor);
635
783
  if (!target || !container.contains(target)) return false;
636
784
 
637
- const stickyActionsEl = container.querySelector<HTMLElement>('[data-sticky-actions]');
638
- const stickyTop = stickyActionsEl
639
- ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0
785
+ const documentHeader = stickyActions
786
+ ? container.querySelector<HTMLElement>('[data-viewer-document-header]')
787
+ ?? container.querySelector<HTMLElement>('[data-sticky-actions]')
788
+ : null;
789
+ const stickyTop = documentHeader
790
+ ? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
640
791
  : 0;
641
- const headerOffset = stickyActionsEl
642
- ? stickyActionsEl.getBoundingClientRect().height + stickyTop
792
+ const headerOffset = documentHeader
793
+ ? documentHeader.getBoundingClientRect().height + stickyTop
643
794
  : 0;
644
795
  const containerRect = getScrollViewportRect(scrollViewport);
645
796
  const targetRect = target.getBoundingClientRect();
@@ -651,7 +802,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
651
802
  behavior: 'smooth',
652
803
  });
653
804
  return true;
654
- }, [scrollViewport]);
805
+ }, [scrollViewport, stickyActions]);
655
806
 
656
807
  useEffect(() => {
657
808
  if (!scrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
@@ -758,7 +909,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
758
909
 
759
910
  if (viewerCommentPopover.isGlobal) {
760
911
  const newAnnotation: Annotation = {
761
- id: `global-${Date.now()}`,
912
+ // randomUUID, not Date.now(): two comments minted in the same
913
+ // millisecond (paste + submit, or a concurrent external write)
914
+ // would collide on a timestamp id.
915
+ id: generateId('global'),
762
916
  blockId: '',
763
917
  startOffset: 0,
764
918
  endOffset: 0,
@@ -788,6 +942,82 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
788
942
 
789
943
  const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
790
944
 
945
+ const documentActions = (
946
+ <>
947
+ {messagePickerInfo && (
948
+ <button
949
+ onClick={messagePickerInfo.onOpen}
950
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
951
+ title="Pick a different message to annotate"
952
+ >
953
+ <MessagesIcon />
954
+ {actionsLabelMode === 'full' && (
955
+ <span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
956
+ )}
957
+ {actionsLabelMode === 'short' && (
958
+ <span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
959
+ )}
960
+ </button>
961
+ )}
962
+
963
+ {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
964
+ <AttachmentsButton
965
+ images={globalAttachments}
966
+ onAdd={onAddGlobalAttachment}
967
+ onRemove={onRemoveGlobalAttachment}
968
+ variant="toolbar"
969
+ hideLabel={actionsLabelMode === 'icon'}
970
+ />
971
+ )}
972
+
973
+ {!readOnly && (
974
+ <button
975
+ ref={globalCommentButtonRef}
976
+ onClick={() => {
977
+ const anchorEl = globalCommentButtonRef.current;
978
+ if (!anchorEl) return;
979
+ setViewerCommentPopover({
980
+ anchorEl,
981
+ contextText: '',
982
+ isGlobal: true,
983
+ });
984
+ }}
985
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
986
+ title="Add global comment"
987
+ >
988
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
989
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
990
+ </svg>
991
+ {actionsLabelMode === 'full' && <span>Global comment</span>}
992
+ {actionsLabelMode === 'short' && <span>Comment</span>}
993
+ </button>
994
+ )}
995
+
996
+ <button
997
+ onClick={handleCopyPlan}
998
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
999
+ title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
1000
+ >
1001
+ {copied ? (
1002
+ <>
1003
+ <svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1004
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
1005
+ </svg>
1006
+ Copied!
1007
+ </>
1008
+ ) : (
1009
+ <>
1010
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1011
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
1012
+ </svg>
1013
+ {actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
1014
+ {actionsLabelMode === 'short' && <span>Copy</span>}
1015
+ </>
1016
+ )}
1017
+ </button>
1018
+ </>
1019
+ );
1020
+
791
1021
  return (
792
1022
  <CodePathValidationContext.Provider value={codePathValidation}>
793
1023
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
@@ -810,8 +1040,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
810
1040
  ...(vimModeActive ? { outline: 'none' } : {}),
811
1041
  } as React.CSSProperties}
812
1042
  >
813
- {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
814
- {(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
1043
+ {/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
1044
+ {!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
815
1045
  <div ref={docBadgesRef} data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
816
1046
  <DocBadges
817
1047
  layout="column"
@@ -831,88 +1061,40 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
831
1061
  </div>
832
1062
  )}
833
1063
 
834
- {/* Clearance so document content starts below the (absolute) badge cluster
835
- when it outgrows the card's top padding — see the measuring effect above. */}
836
- {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
837
-
838
- {/* Sentinel for sticky detection */}
839
- {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
840
-
841
- {/* Header buttons - top right */}
842
- <div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
843
- {messagePickerInfo && (
844
- <button
845
- onClick={messagePickerInfo.onOpen}
846
- className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
847
- title="Pick a different message to annotate"
848
- >
849
- <MessagesIcon />
850
- {actionsLabelMode === 'full' && (
851
- <span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
852
- )}
853
- {actionsLabelMode === 'short' && (
854
- <span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
855
- )}
856
- </button>
857
- )}
858
-
859
- {/* Attachments button */}
860
- {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
861
- <AttachmentsButton
862
- images={globalAttachments}
863
- onAdd={onAddGlobalAttachment}
864
- onRemove={onRemoveGlobalAttachment}
865
- variant="toolbar"
866
- hideLabel={actionsLabelMode === 'icon'}
867
- />
868
- )}
869
-
870
- {/* <span className="md:hidden">Comment</span><span className="hidden md:inline">Global comment</span> button */}
871
- {!readOnly && (
872
- <button
873
- ref={globalCommentButtonRef}
874
- onClick={() => {
875
- setViewerCommentPopover({
876
- anchorEl: globalCommentButtonRef.current!,
877
- contextText: '',
878
- isGlobal: true,
879
- });
1064
+ {viewerAnnotationHeader ? (
1065
+ <ViewerDocumentHeader
1066
+ config={viewerAnnotationHeader}
1067
+ inputMethod={inputMethod}
1068
+ mode={mode}
1069
+ taterMode={taterMode}
1070
+ sticky={stickyActions}
1071
+ stuck={isStuck}
1072
+ sentinelRef={stickySentinelRef}
1073
+ badges={{
1074
+ repoInfo,
1075
+ planDiffStats,
1076
+ isPlanDiffActive,
1077
+ hasPreviousVersion,
1078
+ onPlanDiffToggle,
1079
+ planDiffBaselineLabel,
1080
+ planDiffBaselineTooltip,
1081
+ showDemoBadge,
1082
+ archiveInfo,
1083
+ linkedDocInfo,
1084
+ sourceInfo,
1085
+ openInAppPath,
880
1086
  }}
881
- className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
882
- title="Add global comment"
883
- >
884
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
885
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
886
- </svg>
887
- {actionsLabelMode === 'full' && <span>Global comment</span>}
888
- {actionsLabelMode === 'short' && <span>Comment</span>}
889
- </button>
890
- )}
891
-
892
- {/* Copy plan/file button */}
893
- <button
894
- onClick={handleCopyPlan}
895
- className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
896
- title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
897
- >
898
- {copied ? (
899
- <>
900
- <svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
901
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
902
- </svg>
903
- Copied!
904
- </>
905
- ) : (
906
- <>
907
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
908
- <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
909
- </svg>
910
- {actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
911
- {actionsLabelMode === 'short' && <span>Copy</span>}
912
- </>
913
- )}
914
- </button>
915
- </div>
1087
+ actions={documentActions}
1088
+ />
1089
+ ) : (
1090
+ <>
1091
+ {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1092
+ {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
1093
+ <div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
1094
+ {documentActions}
1095
+ </div>
1096
+ </>
1097
+ )}
916
1098
  {frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
917
1099
  {!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
918
1100
  {groupBlocks(blocks).map(group =>
@@ -194,7 +194,7 @@ export function VimTargetReticle({
194
194
  ? getScrollViewportRect(scrollViewport).top
195
195
  : containerRect.top;
196
196
  const stickyBottom = container
197
- .querySelector<HTMLElement>('[data-sticky-actions]')
197
+ .querySelector<HTMLElement>('[data-viewer-document-header], [data-sticky-actions]')
198
198
  ?.getBoundingClientRect()
199
199
  .bottom;
200
200
  setPosition(relativePosition(
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import type { AlertKind } from '../../types';
3
3
  import { renderProseBody } from './proseBody';
4
+ import { InlineMarkdown } from '../InlineMarkdown';
5
+ import { parseAlertTitleLine } from '../../utils/alertTitle';
4
6
 
5
7
  interface AlertBlockProps {
6
8
  blockId: string;
@@ -22,6 +24,25 @@ const TITLE: Record<AlertKind, string> = {
22
24
  important: 'Important',
23
25
  };
24
26
 
27
+ /**
28
+ * Host seam: resolve a named icon from a title line's `<!-- icon: name -->`
29
+ * comment to a React node. Default: null for every name, so the alert keeps
30
+ * the type's own icon exactly as today. A host that ships an icon set (the
31
+ * package deliberately bundles none) registers a renderer once at startup.
32
+ * Returning null for an unknown name falls back to the type's icon.
33
+ */
34
+ export type AlertIconRenderer = (name: string) => React.ReactNode | null;
35
+
36
+ let alertIconRenderer: AlertIconRenderer | null = null;
37
+
38
+ export function setAlertIconRenderer(renderer: AlertIconRenderer | null): void {
39
+ alertIconRenderer = renderer;
40
+ }
41
+
42
+ export function resetAlertIconRenderer(): void {
43
+ alertIconRenderer = null;
44
+ }
45
+
25
46
  const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
26
47
  const common = { viewBox: '0 0 16 16', width: '16', height: '16', fill: 'currentColor', 'aria-hidden': true as const };
27
48
  switch (kind) {
@@ -41,6 +62,19 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
41
62
  export const AlertBlock: React.FC<AlertBlockProps> = ({
42
63
  blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
43
64
  }) => {
65
+ const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo };
66
+
67
+ // A bold-only first line (optionally led by one emoji, optionally trailed by
68
+ // an `<!-- icon: name -->` comment) is the alert's title and rides the icon
69
+ // row in place of the type word; the emoji, or a host-resolved named icon,
70
+ // takes the icon slot. See utils/alertTitle for the exact grammar. A body
71
+ // with no such line renders exactly as before.
72
+ const titleLine = parseAlertTitleLine(body);
73
+ const hostIcon = titleLine?.icon && alertIconRenderer ? alertIconRenderer(titleLine.icon) : null;
74
+ const icon = titleLine?.emoji
75
+ ? <span aria-hidden="true" className="alert-emoji text-base leading-none">{titleLine.emoji}</span>
76
+ : hostIcon ?? <Icon kind={kind} />;
77
+
44
78
  return (
45
79
  <div
46
80
  className={`alert alert-${kind} my-4 pl-4 pr-3 py-2 border-l-[3px]`}
@@ -48,11 +82,24 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
48
82
  data-block-type="alert"
49
83
  data-alert-kind={kind}
50
84
  >
51
- <div className="alert-title flex items-center gap-2 font-semibold mb-1">
52
- <Icon kind={kind} />
53
- <span>{TITLE[kind]}</span>
85
+ <div
86
+ className="alert-title flex items-center gap-2 font-semibold mb-1"
87
+ aria-label={titleLine?.title ? `${TITLE[kind]}: ${titleLine.title}` : undefined}
88
+ >
89
+ {icon}
90
+ <span>
91
+ {titleLine?.title
92
+ ? <InlineMarkdown text={titleLine.title} {...proseProps} />
93
+ : TITLE[kind]}
94
+ </span>
54
95
  </div>
55
- {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo })}
96
+ {titleLine
97
+ ? (titleLine.rest.trim()
98
+ // Indent the body by the icon's width plus the row gap so it starts
99
+ // at the title's left edge (16px icon + 8px gap = pl-6).
100
+ ? <div className="alert-body pl-6">{renderProseBody({ body: titleLine.rest, ...proseProps })}</div>
101
+ : null)
102
+ : renderProseBody({ body, ...proseProps })}
56
103
  </div>
57
104
  );
58
105
  };
@@ -0,0 +1,52 @@
1
+ /** Responsive presentation chosen for a measured compact document header. */
2
+ export type CompactHeaderLayout = 'wide' | 'tight' | 'narrow';
3
+
4
+ /** Measurements used to place a compact leading lane beside trailing actions. */
5
+ export interface CompactHeaderMeasurements {
6
+ /** Measured width of the full header container. */
7
+ readonly containerWidth: number;
8
+ /** Measured width occupied by the trailing action cluster. */
9
+ readonly trailingWidth: number;
10
+ /** Leading inset that is unavailable to the compact controls. */
11
+ readonly leadingInset: number;
12
+ }
13
+
14
+ /** Resolved responsive geometry for a compact document header. */
15
+ export interface CompactHeaderGeometry {
16
+ /** Pixels available to the leading controls after actions and spacing. */
17
+ readonly availableForLeading: number;
18
+ /** Whether controls keep labels, become icon-only, or stack. */
19
+ readonly layout: CompactHeaderLayout;
20
+ /** Whether both required measurements have arrived. */
21
+ readonly measured: boolean;
22
+ }
23
+
24
+ const GRID_SIZE = 16;
25
+ const CLUSTER_GAP = 16;
26
+ const WIDE_LEADING_WIDTH = 460;
27
+ const MIN_LEADING_WIDTH = 300;
28
+
29
+ /**
30
+ * Quantize a ResizeObserver width to avoid rendering for every dragged pixel.
31
+ * Floors toward the more conservative responsive layout.
32
+ */
33
+ export function snapCompactHeaderWidth(width: number): number {
34
+ return Math.floor(width / GRID_SIZE) * GRID_SIZE;
35
+ }
36
+
37
+ /** Resolve the shared wide, tight, and narrow compact-header states. */
38
+ export function resolveCompactHeaderGeometry({
39
+ containerWidth,
40
+ trailingWidth,
41
+ leadingInset,
42
+ }: CompactHeaderMeasurements): CompactHeaderGeometry {
43
+ const measured = containerWidth > 0 && trailingWidth > 0;
44
+ const availableForLeading = containerWidth - leadingInset - trailingWidth - CLUSTER_GAP;
45
+ const layout = !measured || availableForLeading >= WIDE_LEADING_WIDTH
46
+ ? 'wide'
47
+ : availableForLeading >= MIN_LEADING_WIDTH
48
+ ? 'tight'
49
+ : 'narrow';
50
+
51
+ return { availableForLeading, layout, measured };
52
+ }
@@ -56,6 +56,7 @@ export function getPersistedReviewPanelView(): 'sections' | 'tree' | undefined {
56
56
 
57
57
  export type ReviewDefaultDiffType =
58
58
  | 'since-base'
59
+ | 'local-vs-remote'
59
60
  | 'uncommitted'
60
61
  | 'unstaged'
61
62
  | 'staged'