@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,202 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { ArrowUpRight, Play } from 'lucide-react';
3
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
4
+
5
+ /**
6
+ * One-time announcement for the Plannotator Inbox, on plan review, annotate
7
+ * and code review. Nearly full screen: the Inbox film from plannotator.ai/inbox
8
+ * fills the top, under it one title, one sentence, "Close" and one link to the
9
+ * Inbox page.
10
+ *
11
+ * Built on the shared dialog (`./ui/dialog`, Base UI): modal, focus trapped,
12
+ * focus restored on close, the backdrop and Escape close it. Like the other
13
+ * announcements it swallows Mod+Enter on the capture phase, so a keystroke
14
+ * aimed at it cannot approve a plan or post a review behind it.
15
+ *
16
+ * The film is the marketing page's own file (`apps/marketing/public/assets/
17
+ * inbox-film.mp4`, 1920x1080, ~14 MB, voice-led), served from
18
+ * plannotator.ai/assets like the other announcements' footage. It never
19
+ * autoplays and is `preload="none"`: opening the dialog fetches only the poster
20
+ * (~120 KB), and the film downloads when the reader presses play. Until then
21
+ * the frame is the poster with one play button, as on the Inbox page; after
22
+ * that the native controls take over (sound, seeking, full screen). If the
23
+ * film cannot load, the frame says so and the rest of the dialog is unchanged.
24
+ */
25
+
26
+ /** The Inbox page on plannotator.ai. */
27
+ export const INBOX_PAGE_URL = 'https://plannotator.ai/inbox';
28
+
29
+ /** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
30
+ const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
31
+
32
+ export const INBOX_FILM = {
33
+ mp4: `${MEDIA_BASE_URL}/inbox-film.mp4`,
34
+ poster: `${MEDIA_BASE_URL}/inbox-film-poster.jpg`,
35
+ } as const;
36
+
37
+ /** The film is 1920x1080. */
38
+ const FILM_ASPECT = 16 / 9;
39
+
40
+ /**
41
+ * The panel's width also follows the viewport height, so on a short window the
42
+ * film shrinks until film and footer fit instead of scrolling the film away.
43
+ * 2rem is the overlay's padding, 10rem the footer on a wide panel.
44
+ */
45
+ const PANEL_WIDTH =
46
+ `min(1200px, 100%, calc((var(--pn-viewport-height, 100dvh) - 2rem - 10rem) * ${FILM_ASPECT}))`;
47
+
48
+ interface InboxAnnouncementDialogProps {
49
+ readonly isOpen: boolean;
50
+ /**
51
+ * Marks the announcement seen and closes it. Wired to Close, Escape, the
52
+ * backdrop and the link to the Inbox page.
53
+ */
54
+ readonly onDismiss: () => void;
55
+ }
56
+
57
+ function Film() {
58
+ const videoRef = useRef<HTMLVideoElement>(null);
59
+ const [started, setStarted] = useState(false);
60
+ const [failed, setFailed] = useState(false);
61
+
62
+ const play = () => {
63
+ setStarted(true);
64
+ // A rejected play() (autoplay policy, no media) leaves the native
65
+ // controls up, which offer play again.
66
+ void videoRef.current?.play()?.catch(() => {});
67
+ };
68
+
69
+ return (
70
+ <div className="relative w-full shrink-0 overflow-hidden border-b border-border bg-muted" style={{ aspectRatio: String(FILM_ASPECT) }}>
71
+ <video
72
+ ref={videoRef}
73
+ data-inbox-film
74
+ poster={INBOX_FILM.poster}
75
+ controls={started}
76
+ playsInline
77
+ preload="none"
78
+ aria-label="The Plannotator Inbox film"
79
+ // A source that cannot be fetched errors on <source>; a file that was
80
+ // fetched but cannot be decoded errors on the element itself.
81
+ onError={() => setFailed(true)}
82
+ className="absolute inset-0 h-full w-full bg-muted object-contain"
83
+ >
84
+ <source src={INBOX_FILM.mp4} type="video/mp4" onError={() => setFailed(true)} />
85
+ </video>
86
+
87
+ {failed ? (
88
+ <p
89
+ data-inbox-film-unavailable
90
+ className="absolute inset-0 grid place-items-center bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
91
+ >
92
+ The film could not load. It is also on the Inbox page.
93
+ </p>
94
+ ) : !started ? (
95
+ <button
96
+ type="button"
97
+ onClick={play}
98
+ aria-label="Play the Inbox film"
99
+ data-inbox-film-play
100
+ className="group absolute inset-0 grid place-items-center outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
101
+ >
102
+ <span className="grid size-16 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg backdrop-blur-sm transition-transform motion-reduce:transition-none group-hover:scale-105 sm:size-20">
103
+ <Play className="ml-1 size-6 fill-current sm:size-7" aria-hidden="true" />
104
+ </span>
105
+ </button>
106
+ ) : null}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ export function InboxAnnouncementDialog({ isOpen, onDismiss }: InboxAnnouncementDialogProps) {
112
+ const closeRef = useRef<HTMLButtonElement>(null);
113
+ const onDismissRef = useRef(onDismiss);
114
+
115
+ useEffect(() => {
116
+ onDismissRef.current = onDismiss;
117
+ }, [onDismiss]);
118
+
119
+ useEffect(() => {
120
+ if (!isOpen) return;
121
+ const handleKeyDown = (event: KeyboardEvent) => {
122
+ // Escape is handled here rather than by the dialog so that no window
123
+ // handler of the App behind it (close a panel, leave a mode) sees it.
124
+ if (event.key === 'Escape') {
125
+ event.preventDefault();
126
+ event.stopPropagation();
127
+ onDismissRef.current();
128
+ return;
129
+ }
130
+ // Both apps submit their decision on Mod+Enter from a window-level
131
+ // handler; swallow it on the capture phase.
132
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
133
+ event.preventDefault();
134
+ event.stopPropagation();
135
+ }
136
+ };
137
+ document.addEventListener('keydown', handleKeyDown, true);
138
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
139
+ }, [isOpen]);
140
+
141
+ if (!isOpen) return null;
142
+
143
+ return (
144
+ <Dialog
145
+ open
146
+ onOpenChange={(open) => {
147
+ if (!open) onDismissRef.current();
148
+ }}
149
+ >
150
+ <DialogContent
151
+ hideClose
152
+ initialFocus={closeRef}
153
+ data-inbox-announcement-dialog
154
+ backdropClassName="bg-background/90 backdrop-blur-sm"
155
+ style={{ width: PANEL_WIDTH }}
156
+ className="max-h-full max-w-none rounded-xl bg-card text-card-foreground shadow-2xl motion-reduce:transition-none"
157
+ >
158
+ <Film />
159
+
160
+ <div className="flex min-h-0 flex-col gap-5 overflow-y-auto px-5 py-5 sm:flex-row sm:items-end sm:justify-between sm:gap-8 sm:px-7 sm:py-6">
161
+ <div className="min-w-0">
162
+ <DialogTitle className="text-balance text-xl font-semibold tracking-tight sm:text-2xl">
163
+ The Plannotator Inbox
164
+ </DialogTitle>
165
+ <DialogDescription className="mt-1.5 max-w-xl text-pretty leading-relaxed">
166
+ Questions, blockers and reviews from your agents arrive in one inbox while they work,
167
+ and your answers go back to them.
168
+ </DialogDescription>
169
+ </div>
170
+
171
+ <div className="flex shrink-0 flex-col-reverse gap-2 sm:flex-row sm:items-center">
172
+ <button
173
+ ref={closeRef}
174
+ type="button"
175
+ data-inbox-announcement-close
176
+ onClick={() => onDismissRef.current()}
177
+ className="min-h-10 rounded-lg border border-border px-4 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
178
+ >
179
+ Close
180
+ </button>
181
+ <a
182
+ href={INBOX_PAGE_URL}
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ data-inbox-announcement-learn-more
186
+ // Following the link counts as seeing the announcement. Closed on
187
+ // the next task: a link removed from the page during its own click
188
+ // is not followed.
189
+ onClick={() => {
190
+ setTimeout(() => onDismissRef.current(), 0);
191
+ }}
192
+ className="inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
193
+ >
194
+ Learn about the Inbox
195
+ <ArrowUpRight className="size-4" aria-hidden="true" />
196
+ </a>
197
+ </div>
198
+ </div>
199
+ </DialogContent>
200
+ </Dialog>
201
+ );
202
+ }
@@ -1,9 +1,8 @@
1
- import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
- import { createPortal } from "react-dom";
1
+ import React, { useState, useMemo } from "react";
3
2
  import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
4
- import { forgeRefLinks } from "@plannotator/core/forge-refs";
5
- import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
6
- import { useFenceTheme } from "../hooks/useFenceTheme";
3
+ import { findCrossRepoRef, forgeRefLinks, matchCrossRepoRef, type CrossRepoRef } from "@plannotator/core/forge-refs";
4
+ import { CodeSnippetPreview } from "./CodeSnippetPreview";
5
+ import { useHoverPreview } from "../hooks/useHoverPreview";
7
6
  import { transformPlainText } from "../utils/inlineTransforms";
8
7
  import { hasLinkedDocExtension } from "../utils/markdownExtensions";
9
8
  import { getImageSrc } from "./ImageThumbnail";
@@ -77,101 +76,6 @@ function gateCodePath(
77
76
  }
78
77
  }
79
78
 
80
- function extToLanguage(filepath: string): string | undefined {
81
- const ext = filepath.split('.').pop()?.toLowerCase();
82
- const map: Record<string, string> = {
83
- ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
84
- py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
85
- css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
86
- sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
87
- html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
88
- };
89
- return ext ? map[ext] : undefined;
90
- }
91
-
92
- const CodeSnippetPreview: React.FC<{
93
- anchorEl: HTMLElement | null;
94
- contents: string;
95
- filepath: string;
96
- line: number;
97
- lineEnd?: number;
98
- onMouseEnter?: () => void;
99
- onMouseLeave?: () => void;
100
- }> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => {
101
- const allLines = contents.split('\n');
102
- const start = Math.max(0, line - 1);
103
- const end = Math.min(allLines.length, (lineEnd ?? line));
104
- const snippet = allLines.slice(start, end).join('\n');
105
-
106
- const fenceTheme = useFenceTheme();
107
- // Bumped once the grammar is attached, to re-run the memo below with the
108
- // highlighter warm. Until then the snippet renders as plain text — no
109
- // auto-detection, and an unknown extension simply stays plain.
110
- const [highlighterGeneration, setHighlighterGeneration] = useState(0);
111
- const lang = extToLanguage(filepath);
112
-
113
- useEffect(() => {
114
- if (!lang) return;
115
- let cancelled = false;
116
- void ensureHighlight(lang, fenceTheme).then((ok) => {
117
- if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
118
- });
119
- return () => { cancelled = true; };
120
- }, [lang, fenceTheme]);
121
-
122
- const highlightedLines = useMemo(() => {
123
- // Highlight the snippet as one unit so multi-line constructs (block
124
- // comments, template literals) tokenise correctly, then split back into
125
- // rows: the highlighter joins lines with "\n" and never emits one inside a
126
- // span, so the split is exact.
127
- const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null;
128
- if (html !== null) return html.split('\n');
129
- return snippet.split('\n').map(line =>
130
- line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
131
- // highlighterGeneration is the "grammar just became available" signal.
132
- // eslint-disable-next-line react-hooks/exhaustive-deps
133
- }, [snippet, lang, fenceTheme, highlighterGeneration]);
134
-
135
- if (!anchorEl) return null;
136
-
137
- const rect = anchorEl.getBoundingClientRect();
138
- const spaceBelow = window.innerHeight - rect.bottom;
139
- const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
140
- const top = showAbove ? undefined : rect.bottom + 4;
141
- const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
142
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520));
143
-
144
- return createPortal(
145
- <div
146
- className="fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
147
- style={{ top, bottom, left, maxWidth: 'min(600px, 90vw)', maxHeight: '300px' }}
148
- onMouseEnter={onMouseEnter}
149
- onMouseLeave={onMouseLeave}
150
- >
151
- <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
152
- <span>{filepath.split('/').pop()}</span>
153
- <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
154
- </div>
155
- <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
156
- <table className="border-collapse w-full">
157
- <tbody>
158
- {snippet.split('\n').map((_, i) => (
159
- <tr key={start + i} className="hover:bg-white/5">
160
- <td className="select-none text-muted-foreground/40 text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap" style={{ userSelect: 'none' }}>{start + i + 1}</td>
161
- <td
162
- className="font-mono pr-3 py-0 whitespace-pre"
163
- dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
164
- />
165
- </tr>
166
- ))}
167
- </tbody>
168
- </table>
169
- </div>
170
- </div>,
171
- document.body,
172
- );
173
- };
174
-
175
79
  const CodeFileLink: React.FC<{
176
80
  candidate: string;
177
81
  display: string;
@@ -181,57 +85,16 @@ const CodeFileLink: React.FC<{
181
85
  const validation = useCodePathValidation();
182
86
  const gate = gateCodePath(candidate, validation);
183
87
  const [pickerOpen, setPickerOpen] = useState(false);
184
- const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null);
185
- const hoverPreviewRef = useRef(hoverPreview);
186
- hoverPreviewRef.current = hoverPreview;
187
- const anchorRef = useRef<HTMLElement | null>(null);
188
- const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
189
- const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
88
  const parsed = parseCodePath(candidate);
191
89
  const hasLineRef = parsed.line != null;
192
-
193
- const cancelHide = useCallback(() => {
194
- if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; }
195
- }, []);
196
-
197
- const scheduleHide = useCallback(() => {
198
- cancelHide();
199
- hideTimerRef.current = setTimeout(() => {
200
- setHoverPreview(null);
201
- }, 200);
202
- }, [cancelHide]);
203
-
204
- const handleMouseEnter = useCallback(() => {
205
- if (!hasLineRef || gate.render === 'plain') return;
206
- cancelHide();
207
- if (hoverPreviewRef.current) return;
208
- showTimerRef.current = setTimeout(async () => {
209
- try {
210
- const data = await docPreviewFetcher(candidate, baseDir);
211
- if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate });
212
- } catch {}
213
- }, 150);
214
- }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]);
215
-
216
- const handleMouseLeave = useCallback(() => {
217
- if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
218
- scheduleHide();
219
- }, [scheduleHide]);
220
-
221
- const handlePreviewEnter = useCallback(() => {
222
- cancelHide();
223
- }, [cancelHide]);
224
-
225
- const handlePreviewLeave = useCallback(() => {
226
- scheduleHide();
227
- }, [scheduleHide]);
228
-
229
- useEffect(() => {
230
- return () => {
231
- if (showTimerRef.current) clearTimeout(showTimerRef.current);
232
- if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
233
- };
234
- }, []);
90
+ const hover = useHoverPreview<{ contents: string; filepath: string }>(async () => {
91
+ const data = await docPreviewFetcher(candidate, baseDir);
92
+ return data?.contents ? { contents: data.contents, filepath: data.filepath ?? candidate } : null;
93
+ }, hasLineRef && gate.render !== 'plain');
94
+ const hoverPreview = hover.preview;
95
+ const anchorRef = hover.anchorRef;
96
+ const handleMouseEnter = hover.onEnter;
97
+ const handleMouseLeave = hover.onLeave;
235
98
 
236
99
  if (gate.render === 'plain') {
237
100
  return (
@@ -280,8 +143,7 @@ const CodeFileLink: React.FC<{
280
143
  filepath={hoverPreview.filepath}
281
144
  line={parsed.line!}
282
145
  lineEnd={parsed.lineEnd}
283
- onMouseEnter={handlePreviewEnter}
284
- onMouseLeave={handlePreviewLeave}
146
+ {...hover.previewHandlers}
285
147
  />
286
148
  )}
287
149
  {pickerOpen && isAmbiguous && (
@@ -350,6 +212,9 @@ function canRenderSpacedDollarMath(tex: string): boolean {
350
212
  const trimmed = normalizeMathTex(tex);
351
213
  if (!trimmed) return false;
352
214
  if (!/^\s|\s$/.test(tex)) return true;
215
+ // Padding must be symmetric (`$ y $`). In `$write-better on the copy, and $`
216
+ // the closing `$` is really the opener of the next `$skill` token.
217
+ if (/^\s/.test(tex) !== /\s$/.test(tex)) return false;
353
218
  return (
354
219
  /\\[a-zA-Z]+/.test(trimmed) ||
355
220
  /[=^_{}+\-*/<>|]/.test(trimmed) ||
@@ -380,6 +245,34 @@ export function trimUrlTail(url: string): string {
380
245
  return url;
381
246
  }
382
247
 
248
+ // Index of the first bare `http(s)://` URL that starts after index 0 and
249
+ // before `end` at a word boundary AND runs past `end`, or -1. Only such a
250
+ // URL needs the chunk split: one that ends inside the chunk is linked by
251
+ // emitPlainTextWithBareUrls in the same pass, exactly as before, so a
252
+ // paragraph of many URLs stays one pass instead of a loop turn (and a
253
+ // rescan of the rest of the text) per URL. Index 0 is left to the caller's
254
+ // own bare-URL check, which already ran for it.
255
+ const BARE_URL_SCHEME = /https?:\/\//g;
256
+ const BARE_URL_AT = /https?:\/\/[^\s<>"']+/y;
257
+ function bareUrlRunningPast(text: string, end: number): number {
258
+ // Only a scheme that starts before `end` counts, so read no further than
259
+ // `end` plus the longest scheme ("https://" less its first character).
260
+ const window = text.slice(0, end + 7);
261
+ BARE_URL_SCHEME.lastIndex = 1;
262
+ let m: RegExpExecArray | null;
263
+ while ((m = BARE_URL_SCHEME.exec(window)) !== null && m.index < end) {
264
+ if (/\w/.test(window[m.index - 1])) continue;
265
+ BARE_URL_AT.lastIndex = m.index;
266
+ const url = BARE_URL_AT.exec(text);
267
+ if (!url) continue;
268
+ const urlEnd = m.index + trimUrlTail(url[0]).length;
269
+ if (urlEnd > end) return m.index;
270
+ // The URL ends inside the chunk; look for the next one after it.
271
+ BARE_URL_SCHEME.lastIndex = Math.max(urlEnd, m.index + 1);
272
+ }
273
+ return -1;
274
+ }
275
+
383
276
  // Scan a plain-text chunk for bare https?:// URLs and bare code file paths
384
277
  // at word boundaries, emitting them as interactive nodes. Surrounding text
385
278
  // passes through transformPlainText for emoji shortcodes + smart punctuation.
@@ -474,35 +367,73 @@ function emitPlainTextWithBareUrls(
474
367
  }
475
368
  }
476
369
 
477
- /** An image a host resolver chose; a load failure shows the placeholder. */
370
+ /**
371
+ * An image a host resolver chose; a load failure shows the placeholder.
372
+ *
373
+ * The `<img>` element itself never leaves the document: on failure the
374
+ * placeholder is inserted BEFORE it (React's reference node is then the
375
+ * `<img>`, which it owns) and the `<img>` is hidden. Replacing the `<img>`
376
+ * would make React insert before the node that follows it, and when that is
377
+ * a text node the annotation highlighter already moved into a `<mark>` (a
378
+ * restored comment starting right after the image), `insertBefore` throws
379
+ * and the whole document unmounts. A loaded image's DOM is unchanged.
380
+ */
478
381
  const HostResolvedImage: React.FC<{
479
382
  src: string;
480
383
  alt: string;
384
+ /** The src as written in the markdown (`data-image-src`, read by the image viewer's region comments). */
385
+ writtenSrc?: string;
481
386
  onImageClick?: (src: string, alt: string) => void;
482
- }> = ({ src, alt, onImageClick }) => {
387
+ }> = ({ src, alt, writtenSrc, onImageClick }) => {
483
388
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
484
- if (failedSrc === src) {
485
- return (
486
- <span className={UNAVAILABLE_IMAGE_CLASS} title="Image could not be loaded" data-image-unavailable="">
487
- {alt || 'Image'}
488
- </span>
489
- );
490
- }
389
+ const failed = failedSrc === src;
491
390
  return (
492
- <img
493
- src={src}
494
- alt={alt}
495
- className="max-w-full rounded my-2 cursor-zoom-in"
496
- loading="lazy"
497
- onError={() => setFailedSrc(src)}
498
- onClick={(e) => {
499
- e.stopPropagation();
500
- onImageClick?.(src, alt);
501
- }}
502
- />
391
+ <>
392
+ {failed && (
393
+ <span key="unavailable" className={`${UNAVAILABLE_IMAGE_CLASS} annotation-exclude`} title="Image could not be loaded" data-image-unavailable="">
394
+ {alt || 'Image'}
395
+ </span>
396
+ )}
397
+ <img
398
+ key="image"
399
+ src={src}
400
+ alt={failed ? '' : alt}
401
+ data-image-src={writtenSrc}
402
+ data-image-failed={failed ? '' : undefined}
403
+ hidden={failed || undefined}
404
+ aria-hidden={failed || undefined}
405
+ className={failed ? undefined : 'max-w-full rounded my-2 cursor-zoom-in'}
406
+ loading="lazy"
407
+ onError={() => setFailedSrc(src)}
408
+ onClick={(e) => {
409
+ e.stopPropagation();
410
+ if (!failed) onImageClick?.(src, alt);
411
+ }}
412
+ />
413
+ </>
503
414
  );
504
415
  };
505
416
 
417
+ /**
418
+ * Whether a digit-only 6/8-digit `#123456` is an issue ref (when a ref link can
419
+ * be built) rather than a hex swatch. Off by default: every host renders such
420
+ * values as colours. The Plannotator Inbox turns it on through
421
+ * `configurePlannotatorUI({ digitHexIssueRefs: true })` (#1932).
422
+ */
423
+ let digitHexIssueRefs = false;
424
+
425
+ export function setDigitHexIssueRefs(on: boolean): void {
426
+ digitHexIssueRefs = on;
427
+ }
428
+
429
+ export function resetDigitHexIssueRefs(): void {
430
+ digitHexIssueRefs = false;
431
+ }
432
+
433
+ export function getDigitHexIssueRefs(): boolean {
434
+ return digitHexIssueRefs;
435
+ }
436
+
506
437
  /**
507
438
  * Scanner that walks a text string and emits React nodes for inline markdown:
508
439
  * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~,
@@ -543,6 +474,13 @@ export const InlineMarkdown: React.FC<{
543
474
  const resolveLocalLink = useLocalLinkResolver();
544
475
  const resolveImage = useImageResolver();
545
476
  const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
477
+ // Cross-repo `owner/repo#123` refs: only for a caller that names its
478
+ // project's repo (it knows the forge); otherwise they stay plain text, never
479
+ // a guessed github.com link. With a code-file handler, an `owner/repo` that
480
+ // is a code path not known to be missing stays the file (`src/app.js#12`).
481
+ const crossRepoProject = githubRepo ? { host: repoHost } : null;
482
+ const crossRepoAccept = (ref: CrossRepoRef): boolean =>
483
+ !(onOpenCodeFile && isCodeFilePathStrict(ref.path) && gateCodePath(ref.path, validation ?? null).render !== 'plain');
546
484
  const parts: React.ReactNode[] = [];
547
485
  let remaining = text;
548
486
  let key = 0;
@@ -650,6 +588,32 @@ export const InlineMarkdown: React.FC<{
650
588
  }
651
589
  }
652
590
 
591
+ // Cross-repo ref: owner/repo#123, shown as written, linked on the
592
+ // project's forge host. Read before file-path detection, so
593
+ // `vercel/next.js#1` is a ref, not a path (unless the viewer knows that
594
+ // file, see crossRepoAccept); `docs/guide.md#2` stays a path.
595
+ const crossRepoRef = crossRepoProject ? matchCrossRepoRef(remaining, previousChar, crossRepoProject) : null;
596
+ if (crossRepoRef && crossRepoAccept(crossRepoRef)) {
597
+ parts.push(
598
+ crossRepoRef.href ? (
599
+ <a
600
+ key={key++}
601
+ href={crossRepoRef.href}
602
+ target="_blank"
603
+ rel="noopener noreferrer"
604
+ className="text-primary font-medium hover:underline"
605
+ >
606
+ {crossRepoRef.label}
607
+ </a>
608
+ ) : (
609
+ crossRepoRef.label
610
+ ),
611
+ );
612
+ remaining = remaining.slice(crossRepoRef.label.length);
613
+ previousChar = crossRepoRef.label[crossRepoRef.label.length - 1];
614
+ continue;
615
+ }
616
+
653
617
  // Autolinks: <https://url> or <email@domain.com>
654
618
  match = remaining.match(/^<(https?:\/\/[^>]+)>/);
655
619
  if (match) {
@@ -846,9 +810,19 @@ export const InlineMarkdown: React.FC<{
846
810
  }
847
811
 
848
812
  // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123.
813
+ // 6/8-digit forms with no letter (#186379) are ambiguous. By default they
814
+ // stay swatches (greys like #333333 are common in docs; six-digit issues are
815
+ // rare). A host that opts in with `configurePlannotatorUI({ digitHexIssueRefs:
816
+ // true })` (the Plannotator Inbox, #1932) gets an issue ref instead, but only
817
+ // when a ref link can actually be built (`refLinks`: a repo path on a known
818
+ // forge) and the ref branch below would take it: a word boundary and no
819
+ // leading zero (issue numbers never start with 0, so #000000 stays a colour).
849
820
  match = remaining.match(
850
821
  /^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/,
851
822
  );
823
+ if (match && digitHexIssueRefs && refLinks && /^#[1-9]\d*$/.test(match[1]) && !/\w/.test(previousChar)) {
824
+ match = null;
825
+ }
852
826
  if (match) {
853
827
  const hex = match[1];
854
828
  parts.push(
@@ -872,8 +846,8 @@ export const InlineMarkdown: React.FC<{
872
846
  }
873
847
 
874
848
  // Issue / PR reference: #123 — only at word boundary, digits only.
875
- // Hex-swatch branch above already consumed #fff / #123abc etc., so a bare
876
- // #\d+ here is safe to treat as an issue ref.
849
+ // Hex-swatch branch above already consumed #fff / #123abc etc. (and digit-only
850
+ // #123456 / #12345678 unless the host opted in), so a bare #\d+ here is an issue ref.
877
851
  if (!/\w/.test(previousChar)) {
878
852
  match = remaining.match(/^#(\d+)(?!\w)/);
879
853
  if (match) {
@@ -1027,12 +1001,13 @@ export const InlineMarkdown: React.FC<{
1027
1001
  resolveImage ? (
1028
1002
  // With a host resolver, an image that fails to load becomes the
1029
1003
  // same placeholder instead of a broken-image icon.
1030
- <HostResolvedImage key={key++} src={imgSrc} alt={alt} onImageClick={onImageClick} />
1004
+ <HostResolvedImage key={key++} src={imgSrc} alt={alt} writtenSrc={src} onImageClick={onImageClick} />
1031
1005
  ) : (
1032
1006
  <img
1033
1007
  key={key++}
1034
1008
  src={imgSrc}
1035
1009
  alt={alt}
1010
+ data-image-src={src}
1036
1011
  className="max-w-full rounded my-2 cursor-zoom-in"
1037
1012
  loading="lazy"
1038
1013
  onClick={(e) => {
@@ -1272,6 +1247,24 @@ export const InlineMarkdown: React.FC<{
1272
1247
  // multi-char patterns like emoji shortcodes. Bare URLs are instead
1273
1248
  // detected inline via emitPlainTextWithBareUrls() below.
1274
1249
  const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@$]/);
1250
+ // A bare URL that starts inside this plain-text chunk and runs past its
1251
+ // end would be cut there, and `_`, `~`, `#`, `@`, `$` and `!` all end a
1252
+ // chunk (`…/w/ws_01…` rendered a link to `…/w/ws`). Stop the chunk where
1253
+ // that URL starts instead, so the bare-URL branch above takes it whole.
1254
+ const chunkEnd = nextSpecial === -1 ? remaining.length : nextSpecial + 1;
1255
+ // Same for an owner/repo#123 ref: `#` (and `_` in a name) ends a chunk, so
1256
+ // stop before the ref and let the cross-repo branch above take it whole.
1257
+ // A ref inside a URL never splits it: the URL starts first and wins.
1258
+ const urlAt = bareUrlRunningPast(remaining, chunkEnd);
1259
+ const refAt = crossRepoProject ? findCrossRepoRef(remaining, urlAt === -1 ? chunkEnd : urlAt, crossRepoProject, crossRepoAccept) : -1;
1260
+ const splitAt = refAt !== -1 ? refAt : urlAt;
1261
+ if (splitAt !== -1) {
1262
+ const before = remaining.slice(0, splitAt);
1263
+ emitPlainTextWithBareUrls(before, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
1264
+ previousChar = before[before.length - 1] || previousChar;
1265
+ remaining = remaining.slice(splitAt);
1266
+ continue;
1267
+ }
1275
1268
  const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1);
1276
1269
  emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir);
1277
1270
  previousChar = plainText[plainText.length - 1] || previousChar;