@plannotator/ui 0.39.0 → 0.41.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 (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -0,0 +1,454 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Pause, Play } from 'lucide-react';
4
+
5
+ /**
6
+ * One-time announcement for Plannotator's two terminal clients, Plannotator TUI
7
+ * and Herdr Annotate. Video first: the real demo footage fills the top of the
8
+ * panel edge to edge, and the text below it is one headline and one sentence.
9
+ * Same shell as the other first-run announcements (portal, z-[100], hand-rolled
10
+ * Escape + Tab wrap + focus restore), with one difference: it asks the user to
11
+ * decide nothing, so Escape, the backdrop and the single "Got it" button all do
12
+ * the same thing.
13
+ *
14
+ * LAST in each app's first-run dialog chain. The Apps gate rendering through
15
+ * terminalToolsAnnouncementCanShow so the chain dialogs never stack; see that
16
+ * function for why last rather than first.
17
+ *
18
+ * The footage is hosted on plannotator.ai like GuideIntroDialog's hero image
19
+ * (the two demos together are ~23MB, far too much to inline the way the Edit
20
+ * Mode recording is), so the dialog has to look right without it: the poster
21
+ * stays up while the video buffers, and if the media cannot load at all the
22
+ * frame keeps its place and offers the X post instead.
23
+ */
24
+
25
+ const TUI_REPO = 'https://github.com/plannotator/plannotator-tui';
26
+ const HERDR_REPO = 'https://github.com/plannotator/herdr-annotate';
27
+
28
+ /** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
29
+ const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
30
+
31
+ export interface TerminalToolsDemo {
32
+ readonly id: 'full' | 'lite';
33
+ /** Segment label. Herdr Annotate's own naming for its two install targets. */
34
+ readonly label: string;
35
+ readonly mp4: string;
36
+ readonly webm: string;
37
+ readonly poster: string;
38
+ /** The X post the footage was cut from. */
39
+ readonly watchUrl: string;
40
+ readonly description: string;
41
+ }
42
+
43
+ /** 1280x806 and 1280x808: one aspect ratio, so switching never reflows the panel. */
44
+ const DEMO_ASPECT = 1280 / 806;
45
+
46
+ /**
47
+ * Herdr Annotate's own colors, from its repo badges (assets/install-full.svg
48
+ * and siblings): badge purple, lavender text, periwinkle accent. They belong to
49
+ * another product, so they live as local variables on this dialog and never
50
+ * enter theme.css. The badge purple is the one that still reads as a purple
51
+ * block over the dark footage; the darker hero ground (#10101f-#2c2536) would
52
+ * vanish into the frame.
53
+ */
54
+ const HERDR_BRAND_VARS = {
55
+ '--announce-brand': '#312b52',
56
+ '--announce-brand-text': '#c9c6f1',
57
+ '--announce-brand-accent': '#B9C0FF',
58
+ } as React.CSSProperties;
59
+
60
+ export const TERMINAL_TOOLS_DEMOS: readonly TerminalToolsDemo[] = [
61
+ {
62
+ id: 'full',
63
+ label: 'Full',
64
+ mp4: `${MEDIA_BASE_URL}/tui-herdr-full-demo.mp4`,
65
+ webm: `${MEDIA_BASE_URL}/tui-herdr-full-demo.webm`,
66
+ poster: `${MEDIA_BASE_URL}/tui-herdr-full-poster.jpg`,
67
+ watchUrl: 'https://x.com/plannotator/status/2093419561077154287',
68
+ description:
69
+ 'Herdr Annotate reviewing a Markdown file with Plannotator TUI: a file tree, a selected block with its comment, and the feedback sent to the agent.',
70
+ },
71
+ {
72
+ id: 'lite',
73
+ label: 'Lite',
74
+ mp4: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.mp4`,
75
+ webm: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.webm`,
76
+ poster: `${MEDIA_BASE_URL}/tui-herdr-lite-poster.jpg`,
77
+ watchUrl: 'https://x.com/plannotator/status/2092757422322627008',
78
+ description:
79
+ 'Herdr Annotate Lite: terminal text selected in an agent session, a comment written in a popover, and the notes sent back to the agent.',
80
+ },
81
+ ];
82
+
83
+ interface TerminalToolsAnnouncementDialogProps {
84
+ readonly isOpen: boolean;
85
+ /** Marks the announcement seen and closes it. Also wired to Escape and the backdrop. */
86
+ readonly onDismiss: () => void;
87
+ /**
88
+ * Test seam. Defaults to the media query; forcing it lets a test assert the
89
+ * reduced-motion branch without a real `matchMedia`.
90
+ */
91
+ readonly reducedMotion?: boolean;
92
+ }
93
+
94
+ export function prefersReducedMotion(): boolean {
95
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
96
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
97
+ }
98
+
99
+ function GitHubMark({ className }: { readonly className?: string }) {
100
+ return (
101
+ <svg viewBox="0 0 16 16" className={className} aria-hidden="true" focusable="false">
102
+ <path
103
+ fill="currentColor"
104
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
105
+ />
106
+ </svg>
107
+ );
108
+ }
109
+
110
+ function XMark({ className }: { readonly className?: string }) {
111
+ return (
112
+ <svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
113
+ <path
114
+ fill="currentColor"
115
+ d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117Z"
116
+ />
117
+ </svg>
118
+ );
119
+ }
120
+
121
+ function OutboundAction({
122
+ href,
123
+ children,
124
+ icon,
125
+ }: {
126
+ readonly href: string;
127
+ readonly children: React.ReactNode;
128
+ readonly icon: React.ReactNode;
129
+ }) {
130
+ return (
131
+ <a
132
+ href={href}
133
+ target="_blank"
134
+ rel="noopener noreferrer"
135
+ className="inline-flex min-h-9 items-center gap-2 rounded-lg border border-border bg-surface-0/40 px-3 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-surface-1/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
136
+ >
137
+ {icon}
138
+ {children}
139
+ </a>
140
+ );
141
+ }
142
+
143
+ interface DemoPlayerProps {
144
+ readonly demo: TerminalToolsDemo;
145
+ readonly reducedMotion: boolean;
146
+ }
147
+
148
+ /**
149
+ * The footage. Muted, looping, inline; autoplays unless the reader asked for
150
+ * reduced motion, in which case the poster waits behind a play button. One
151
+ * toggle serves both: large and centered while paused, tucked into a corner
152
+ * while playing, so a paused frame is never mistaken for a broken one.
153
+ */
154
+ function DemoPlayer({ demo, reducedMotion }: DemoPlayerProps) {
155
+ const videoRef = useRef<HTMLVideoElement>(null);
156
+ // Optimistic: autoplay is expected, so the corner control is the initial
157
+ // shape and the centered Play only appears once the browser has proven it
158
+ // will not start (canplaythrough with the element still paused).
159
+ const [playing, setPlaying] = useState(!reducedMotion);
160
+ const [failed, setFailed] = useState(false);
161
+
162
+ const toggle = useCallback(() => {
163
+ const video = videoRef.current;
164
+ if (!video) return;
165
+ if (video.paused) {
166
+ // Autoplay policies can reject play(); the button simply stays put.
167
+ void video.play().catch(() => {});
168
+ } else {
169
+ video.pause();
170
+ }
171
+ }, []);
172
+
173
+ return (
174
+ <div
175
+ className="group relative w-full overflow-hidden bg-muted"
176
+ style={{ aspectRatio: String(DEMO_ASPECT) }}
177
+ >
178
+ {/* A corner block on the frame, flush to the panel's top-left edge (the
179
+ panel's own radius clips its outer corner) the way a "new" corner tag
180
+ sits on a product card. The posters keep only a sidebar label and a
181
+ tab marker under this corner, so nothing that matters is covered.
182
+ Roughly 200x48 over the desktop video, 118x32 at phone width. */}
183
+ <span
184
+ aria-hidden="true"
185
+ data-terminal-tools-tag
186
+ data-shimmer={reducedMotion ? 'off' : 'on'}
187
+ className={`terminal-tools-announcement-tag absolute left-0 top-0 z-10 inline-flex h-8 select-none items-center rounded-br-xl pl-3.5 pr-4 text-[11px] font-semibold uppercase leading-none tracking-[0.16em] sm:h-12 sm:pl-6 sm:pr-7 sm:text-[15px] sm:tracking-[0.18em]${
188
+ reducedMotion ? '' : ' terminal-tools-announcement-tag--sheen'
189
+ }`}
190
+ >
191
+ New · Watch:
192
+ </span>
193
+ <video
194
+ ref={videoRef}
195
+ data-terminal-tools-demo={demo.id}
196
+ poster={demo.poster}
197
+ muted
198
+ playsInline
199
+ loop
200
+ autoPlay={!reducedMotion}
201
+ preload="auto"
202
+ aria-label={demo.description}
203
+ onPlay={() => setPlaying(true)}
204
+ onPause={() => setPlaying(false)}
205
+ onCanPlayThrough={(event) => setPlaying(!event.currentTarget.paused)}
206
+ onClick={toggle}
207
+ className="absolute inset-0 h-full w-full object-cover"
208
+ >
209
+ <source src={demo.mp4} type="video/mp4" />
210
+ {/* The last source is the one whose error means nothing could load. */}
211
+ <source src={demo.webm} type="video/webm" onError={() => setFailed(true)} />
212
+ </video>
213
+
214
+ {failed ? (
215
+ <div className="absolute inset-0 grid place-items-center bg-background/70 backdrop-blur-sm">
216
+ <a
217
+ href={demo.watchUrl}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-border bg-card px-4 text-sm font-medium text-foreground outline-none hover:bg-surface-1 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
221
+ >
222
+ <XMark className="size-3.5" />
223
+ Watch on X
224
+ </a>
225
+ </div>
226
+ ) : (
227
+ <button
228
+ type="button"
229
+ onClick={toggle}
230
+ aria-label={playing ? 'Pause demo' : 'Play demo'}
231
+ data-terminal-tools-playback={playing ? 'pause' : 'play'}
232
+ className={
233
+ playing
234
+ ? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
235
+ : 'absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg outline-none backdrop-blur-sm transition-transform motion-reduce:transition-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary'
236
+ }
237
+ >
238
+ {playing ? (
239
+ <Pause className="size-4" aria-hidden="true" />
240
+ ) : (
241
+ <Play className="ml-0.5 size-6 fill-current" aria-hidden="true" />
242
+ )}
243
+ </button>
244
+ )}
245
+ </div>
246
+ );
247
+ }
248
+
249
+ interface DemoSwitchProps {
250
+ readonly active: TerminalToolsDemo['id'];
251
+ readonly onChange: (id: TerminalToolsDemo['id']) => void;
252
+ }
253
+
254
+ function DemoSwitch({ active, onChange }: DemoSwitchProps) {
255
+ const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
256
+
257
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
258
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
259
+ event.preventDefault();
260
+ const index = TERMINAL_TOOLS_DEMOS.findIndex((demo) => demo.id === active);
261
+ const step = event.key === 'ArrowRight' ? 1 : -1;
262
+ const next = TERMINAL_TOOLS_DEMOS[
263
+ (index + step + TERMINAL_TOOLS_DEMOS.length) % TERMINAL_TOOLS_DEMOS.length
264
+ ];
265
+ onChange(next.id);
266
+ tabRefs.current[next.id]?.focus();
267
+ };
268
+
269
+ return (
270
+ <div
271
+ role="tablist"
272
+ aria-label="Demo"
273
+ className="inline-flex shrink-0 rounded-lg border border-border bg-surface-0/60 p-0.5"
274
+ >
275
+ {TERMINAL_TOOLS_DEMOS.map((demo) => {
276
+ const selected = demo.id === active;
277
+ return (
278
+ <button
279
+ key={demo.id}
280
+ ref={(node) => {
281
+ tabRefs.current[demo.id] = node;
282
+ }}
283
+ type="button"
284
+ role="tab"
285
+ aria-selected={selected}
286
+ aria-controls="terminal-tools-announcement-demo"
287
+ tabIndex={selected ? 0 : -1}
288
+ onClick={() => onChange(demo.id)}
289
+ onKeyDown={handleKeyDown}
290
+ className={`min-h-8 rounded-md px-3 text-xs font-medium outline-none transition-colors motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-primary ${
291
+ selected
292
+ ? 'bg-card text-foreground shadow-sm'
293
+ : 'text-muted-foreground hover:text-foreground'
294
+ }`}
295
+ >
296
+ {demo.label}
297
+ </button>
298
+ );
299
+ })}
300
+ </div>
301
+ );
302
+ }
303
+
304
+ export function TerminalToolsAnnouncementDialog({
305
+ isOpen,
306
+ onDismiss,
307
+ reducedMotion,
308
+ }: TerminalToolsAnnouncementDialogProps) {
309
+ const dismissRef = useRef<HTMLButtonElement>(null);
310
+ const previousFocusRef = useRef<HTMLElement | null>(null);
311
+ const onDismissRef = useRef(onDismiss);
312
+ const [activeDemoId, setActiveDemoId] = useState<TerminalToolsDemo['id']>('full');
313
+ const [motionPreference] = useState(() => reducedMotion ?? prefersReducedMotion());
314
+ const reduced = reducedMotion ?? motionPreference;
315
+ const activeDemo =
316
+ TERMINAL_TOOLS_DEMOS.find((demo) => demo.id === activeDemoId) ?? TERMINAL_TOOLS_DEMOS[0];
317
+
318
+ useEffect(() => {
319
+ onDismissRef.current = onDismiss;
320
+ }, [onDismiss]);
321
+
322
+ useEffect(() => {
323
+ if (!isOpen) return;
324
+
325
+ previousFocusRef.current = document.activeElement instanceof HTMLElement
326
+ ? document.activeElement
327
+ : null;
328
+ dismissRef.current?.focus();
329
+
330
+ const handleKeyDown = (event: KeyboardEvent) => {
331
+ if (event.key === 'Escape') {
332
+ event.preventDefault();
333
+ event.stopPropagation();
334
+ onDismissRef.current();
335
+ return;
336
+ }
337
+ // Both apps submit their decision on Mod+Enter from a window-level
338
+ // handler. This listener is registered on the capture phase, so
339
+ // swallowing the chord here is what stops a keystroke aimed at the
340
+ // announcement from approving a plan or posting a review behind it.
341
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
342
+ event.preventDefault();
343
+ event.stopPropagation();
344
+ return;
345
+ }
346
+ if (event.key !== 'Tab') return;
347
+
348
+ const dialog = document.querySelector<HTMLElement>('[data-terminal-tools-announcement-dialog]');
349
+ const focusable = Array.from(
350
+ dialog?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')
351
+ ?? [],
352
+ ).filter((element) => element.getAttribute('tabindex') !== '-1');
353
+ if (focusable.length === 0) return;
354
+
355
+ const first = focusable[0];
356
+ const last = focusable[focusable.length - 1];
357
+ if (event.shiftKey && document.activeElement === first) {
358
+ event.preventDefault();
359
+ last.focus();
360
+ } else if (!event.shiftKey && document.activeElement === last) {
361
+ event.preventDefault();
362
+ first.focus();
363
+ }
364
+ };
365
+
366
+ document.addEventListener('keydown', handleKeyDown, true);
367
+ return () => {
368
+ document.removeEventListener('keydown', handleKeyDown, true);
369
+ previousFocusRef.current?.focus();
370
+ previousFocusRef.current = null;
371
+ };
372
+ }, [isOpen]);
373
+
374
+ if (!isOpen) return null;
375
+
376
+ return createPortal(
377
+ <div
378
+ className="terminal-tools-announcement-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-background/90 p-4 backdrop-blur-sm"
379
+ // Dismissing from the backdrop is safe here because the dialog collects
380
+ // no decision: there is nothing to lose by closing it the impatient way.
381
+ onMouseDown={(event) => {
382
+ if (event.target === event.currentTarget) onDismissRef.current();
383
+ }}
384
+ >
385
+ <div
386
+ data-terminal-tools-announcement-dialog
387
+ role="dialog"
388
+ aria-modal="true"
389
+ aria-labelledby="terminal-tools-announcement-title"
390
+ aria-describedby="terminal-tools-announcement-description"
391
+ // Width follows the viewport height as well as its width: the footage
392
+ // keeps its aspect ratio, so on a short window the panel narrows until
393
+ // video plus footer fit instead of scrolling the video out of view.
394
+ // The 12rem is the footer's height, with the wrap at narrow widths.
395
+ style={{
396
+ ...HERDR_BRAND_VARS,
397
+ width: `min(1120px, 100%, calc((100dvh - 2rem - 12rem) * ${DEMO_ASPECT}))`,
398
+ }}
399
+ className="terminal-tools-announcement-dialog flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
400
+ >
401
+ <div
402
+ id="terminal-tools-announcement-demo"
403
+ role="tabpanel"
404
+ aria-label={`${activeDemo.label} demo`}
405
+ className="shrink-0 border-b border-border"
406
+ >
407
+ {/* Keyed so a switch resets playback and load-failure state with the footage. */}
408
+ <DemoPlayer key={activeDemo.id} demo={activeDemo} reducedMotion={reduced} />
409
+ </div>
410
+
411
+ <div className="min-h-0 overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
412
+ <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
413
+ <div className="min-w-0">
414
+ <h2
415
+ id="terminal-tools-announcement-title"
416
+ className="text-balance text-xl font-semibold tracking-tight sm:text-2xl"
417
+ >
418
+ Plannotator TUI and Herdr Annotate
419
+ </h2>
420
+ <p
421
+ id="terminal-tools-announcement-description"
422
+ className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground"
423
+ >
424
+ Annotate Markdown and terminal text, then send the notes to your agent.
425
+ </p>
426
+ </div>
427
+ <DemoSwitch active={activeDemo.id} onChange={setActiveDemoId} />
428
+ </div>
429
+
430
+ <div className="mt-5 flex flex-wrap items-center gap-2">
431
+ <OutboundAction href={TUI_REPO} icon={<GitHubMark className="size-4" />}>
432
+ Star plannotator-tui
433
+ </OutboundAction>
434
+ <OutboundAction href={HERDR_REPO} icon={<GitHubMark className="size-4" />}>
435
+ Star herdr-annotate
436
+ </OutboundAction>
437
+ <OutboundAction href={activeDemo.watchUrl} icon={<XMark className="size-3.5" />}>
438
+ Watch on X
439
+ </OutboundAction>
440
+ <button
441
+ ref={dismissRef}
442
+ type="button"
443
+ onClick={onDismiss}
444
+ className="ml-auto min-h-9 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"
445
+ >
446
+ Got it
447
+ </button>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>,
452
+ document.body,
453
+ );
454
+ }
@@ -40,6 +40,7 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
40
40
  import { TaterSpriteSitting } from './TaterSpriteSitting';
41
41
  import { AttachmentsButton } from './AttachmentsButton';
42
42
  import { MessagesIcon } from './icons/MessagesIcon';
43
+ import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
43
44
  import { GraphvizBlock } from './GraphvizBlock';
44
45
  import { MermaidBlock } from './MermaidBlock';
45
46
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
@@ -48,7 +49,7 @@ import { type QuickLabel } from '../utils/quickLabels';
48
49
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
49
50
  import { PinpointOverlay } from './PinpointOverlay';
50
51
  import { usePinpoint } from '../hooks/usePinpoint';
51
- import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
52
+ import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
52
53
  import { useVimSelection } from '../hooks/useVimSelection';
53
54
  import {
54
55
  getScrollViewportIntersectionRoot,
@@ -167,6 +168,9 @@ export interface ViewerProps {
167
168
  vimHudKeyPanelEnabled?: boolean;
168
169
  /** Persist a user request to hide the bottom-right key panel. */
169
170
  onVimHudKeyPanelChange?: (enabled: boolean) => void;
171
+ /** Fires once per highlight-restore pass with what it tried and what it could
172
+ * not anchor, so a host can mark the leftovers in its annotation panel. */
173
+ onRestoreReport?: (report: AnnotationRestoreReport) => void;
170
174
  }
171
175
 
172
176
  export interface ViewerHandle {
@@ -364,6 +368,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
364
368
  imageBaseDir,
365
369
  codePathBaseDir,
366
370
  disableCodePathValidation,
371
+ onRestoreReport,
367
372
  copyLabel,
368
373
  actionsLabelMode = 'full',
369
374
  archiveInfo,
@@ -463,6 +468,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
463
468
  const lastAutoScrolledHashRef = useRef<string | null>(null);
464
469
  const [isStuck, setIsStuck] = useState(false);
465
470
 
471
+ // Reported only when the text-search rescue could not re-anchor either, so
472
+ // the annotation is listed in the panel with no highlight in the document.
473
+ const handleRestoreMismatch = useCallback((annotation: Annotation, restoredText: string) => {
474
+ console.warn(
475
+ `Annotation ${annotation.id} could not be re-anchored: stored positions resolved onto ` +
476
+ `"${restoredText.slice(0, 50)}" and its text "${annotation.originalText.slice(0, 50)}" is no longer in the document.`,
477
+ );
478
+ }, []);
479
+
466
480
  // Shared annotation infrastructure via hook
467
481
  const {
468
482
  toolbarState,
@@ -489,6 +503,17 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
489
503
  selectedAnnotationId,
490
504
  mode,
491
505
  enabled: !readOnly,
506
+ // Markdown documents drift between the moment a draft/share is written and
507
+ // the moment it is restored (a plan revision, a re-rendered block, a
508
+ // renderer change that adds or drops elements — #1509 hoisted an alert's
509
+ // bold first line onto the icon row, which renumbers every later
510
+ // `parentIndex`). web-highlighter's stored metas are positional, so a
511
+ // drifted anchor resolves onto the WRONG text and paints silently. Verify
512
+ // the painted text against the annotation's own quote so a bad resolve is
513
+ // dropped and the text-search rescue runs instead of a wrong highlight.
514
+ verifyRestoredContent: true,
515
+ onRestoreMismatch: handleRestoreMismatch,
516
+ onRestoreReport,
492
517
  });
493
518
 
494
519
  // Refs for code block annotation path
@@ -1018,8 +1043,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1018
1043
  </>
1019
1044
  );
1020
1045
 
1046
+ // The document's diagram blocks, in order: a diagram comment that names
1047
+ // none of them (an external POST, a deleted fence) is resolved by anchor
1048
+ // against each, first resolver wins (see diagram/anchorClaims).
1049
+ const diagramBlockKey = blocks
1050
+ .filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
1051
+ .map((b) => b.id)
1052
+ .join('\n');
1053
+ const diagramClaims = useMemo(
1054
+ () => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
1055
+ [diagramBlockKey],
1056
+ );
1057
+ // With no diagram in the document nobody can resolve a diagram comment:
1058
+ // it is unanchored, and the highlighter (which skips it) will not say so.
1059
+ useEffect(() => {
1060
+ if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
1061
+ const ids = annotations.filter((ann) => ann.diagramAnchor !== undefined).map((ann) => ann.id);
1062
+ if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
1063
+ }, [annotations, diagramBlockKey, onRestoreReport]);
1064
+
1021
1065
  return (
1022
1066
  <CodePathValidationContext.Provider value={codePathValidation}>
1067
+ <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1023
1068
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1024
1069
  {taterMode && <TaterSpriteSitting />}
1025
1070
  <article
@@ -1123,9 +1168,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1123
1168
  );
1124
1169
  })()
1125
1170
  ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
1126
- <MermaidBlock key={group.block.id} block={group.block} />
1171
+ <MermaidBlock
1172
+ key={group.block.id}
1173
+ block={group.block}
1174
+ annotations={annotations}
1175
+ selectedAnnotationId={selectedAnnotationId}
1176
+ onSelectAnnotation={onSelectAnnotation}
1177
+ onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1178
+ readOnly={readOnly}
1179
+ onRestoreReport={onRestoreReport}
1180
+ />
1127
1181
  ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
1128
- <GraphvizBlock key={group.block.id} block={group.block} />
1182
+ <GraphvizBlock
1183
+ key={group.block.id}
1184
+ block={group.block}
1185
+ annotations={annotations}
1186
+ selectedAnnotationId={selectedAnnotationId}
1187
+ onSelectAnnotation={onSelectAnnotation}
1188
+ onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1189
+ readOnly={readOnly}
1190
+ onRestoreReport={onRestoreReport}
1191
+ />
1129
1192
  ) : group.block.type === 'table' ? (
1130
1193
  <TableBlock
1131
1194
  key={group.block.id}
@@ -1420,6 +1483,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1420
1483
  document.body
1421
1484
  )}
1422
1485
  </div>
1486
+ </DiagramAnchorClaimsContext.Provider>
1423
1487
  </CodePathValidationContext.Provider>
1424
1488
  );
1425
1489
  });
@@ -91,8 +91,13 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
91
91
  <>
92
92
  {/* The type word stays part of the accessible name. A visually
93
93
  hidden span is read by every engine; an aria-label on this
94
- generic div is prohibited by ARIA and dropped by WebKit. */}
95
- <span className="sr-only">{TITLE[kind]}: </span>
94
+ generic div is prohibited by ARIA and dropped by WebKit.
95
+ `annotation-exclude` keeps it out of the annotation text
96
+ stream: without it a drag that starts on the icon quotes
97
+ the invisible word ("Warning:Named icon title"), which is
98
+ what the panel shows, what the agent is handed, and a quote
99
+ no share-link restore can find again. */}
100
+ <span className="sr-only annotation-exclude">{TITLE[kind]}: </span>
96
101
  <InlineMarkdown text={titleLine.title} {...proseProps} />
97
102
  </>
98
103
  )