@plannotator/ui 0.39.0 → 0.40.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.
@@ -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
+ }
@@ -48,7 +48,7 @@ import { type QuickLabel } from '../utils/quickLabels';
48
48
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
49
49
  import { PinpointOverlay } from './PinpointOverlay';
50
50
  import { usePinpoint } from '../hooks/usePinpoint';
51
- import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
51
+ import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
52
52
  import { useVimSelection } from '../hooks/useVimSelection';
53
53
  import {
54
54
  getScrollViewportIntersectionRoot,
@@ -167,6 +167,9 @@ export interface ViewerProps {
167
167
  vimHudKeyPanelEnabled?: boolean;
168
168
  /** Persist a user request to hide the bottom-right key panel. */
169
169
  onVimHudKeyPanelChange?: (enabled: boolean) => void;
170
+ /** Fires once per highlight-restore pass with what it tried and what it could
171
+ * not anchor, so a host can mark the leftovers in its annotation panel. */
172
+ onRestoreReport?: (report: AnnotationRestoreReport) => void;
170
173
  }
171
174
 
172
175
  export interface ViewerHandle {
@@ -364,6 +367,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
364
367
  imageBaseDir,
365
368
  codePathBaseDir,
366
369
  disableCodePathValidation,
370
+ onRestoreReport,
367
371
  copyLabel,
368
372
  actionsLabelMode = 'full',
369
373
  archiveInfo,
@@ -463,6 +467,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
463
467
  const lastAutoScrolledHashRef = useRef<string | null>(null);
464
468
  const [isStuck, setIsStuck] = useState(false);
465
469
 
470
+ // Reported only when the text-search rescue could not re-anchor either, so
471
+ // the annotation is listed in the panel with no highlight in the document.
472
+ const handleRestoreMismatch = useCallback((annotation: Annotation, restoredText: string) => {
473
+ console.warn(
474
+ `Annotation ${annotation.id} could not be re-anchored: stored positions resolved onto ` +
475
+ `"${restoredText.slice(0, 50)}" and its text "${annotation.originalText.slice(0, 50)}" is no longer in the document.`,
476
+ );
477
+ }, []);
478
+
466
479
  // Shared annotation infrastructure via hook
467
480
  const {
468
481
  toolbarState,
@@ -489,6 +502,17 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
489
502
  selectedAnnotationId,
490
503
  mode,
491
504
  enabled: !readOnly,
505
+ // Markdown documents drift between the moment a draft/share is written and
506
+ // the moment it is restored (a plan revision, a re-rendered block, a
507
+ // renderer change that adds or drops elements — #1509 hoisted an alert's
508
+ // bold first line onto the icon row, which renumbers every later
509
+ // `parentIndex`). web-highlighter's stored metas are positional, so a
510
+ // drifted anchor resolves onto the WRONG text and paints silently. Verify
511
+ // the painted text against the annotation's own quote so a bad resolve is
512
+ // dropped and the text-search rescue runs instead of a wrong highlight.
513
+ verifyRestoredContent: true,
514
+ onRestoreMismatch: handleRestoreMismatch,
515
+ onRestoreReport,
492
516
  });
493
517
 
494
518
  // Refs for code block annotation path
@@ -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
  )
@@ -190,6 +190,18 @@ export interface HtmlViewerProps {
190
190
  currentPageUrl?: string;
191
191
  /** Live-mode page navigation reports (ready pageUrl + page-change). */
192
192
  onPageChange?: (pageUrl: string) => void;
193
+ /** A link the framed srcdoc document swallowed rather than navigating to.
194
+ * A srcdoc document's base URL is the PARENT page's, so an unhandled
195
+ * `<a href="other.html">` would load the host app inside the frame; the
196
+ * bridge suppresses every such navigation and relays the RAW href here
197
+ * (bounded and screened at the trust boundary) for the host to resolve —
198
+ * see `resolveHtmlLinkIntent`. In-page `#fragment` links are scrolled by
199
+ * the bridge and never reported. Never fires in live (`src`) sessions,
200
+ * which navigate the proxied app for real. */
201
+ onOpenLink?: (href: string) => void;
202
+ /** Fragment to scroll to once this document's bridge is ready, for a
203
+ * linked document opened from a `#`-carrying link. Bare id, no `#`. */
204
+ initialFragment?: string;
193
205
  annotations: Annotation[];
194
206
  onAddAnnotation: (ann: Annotation) => void;
195
207
  onSelectAnnotation: (id: string | null) => void;
@@ -209,6 +221,9 @@ export interface HtmlViewerProps {
209
221
  onAnnotateModeExit?: () => void;
210
222
  /** Mod+Shift+A pressed while focus lived inside the iframe. */
211
223
  onAnnotateModeToggle?: () => void;
224
+ /** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the
225
+ * host's floating tools over the page. The host owns that state. */
226
+ onToolsToggle?: () => void;
212
227
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
213
228
  vimModeEnabled?: boolean;
214
229
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -308,6 +323,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
308
323
  liveSession,
309
324
  currentPageUrl,
310
325
  onPageChange,
326
+ onOpenLink,
327
+ initialFragment,
311
328
  annotations,
312
329
  onAddAnnotation,
313
330
  onSelectAnnotation,
@@ -317,6 +334,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
317
334
  annotateModeActive = true,
318
335
  onAnnotateModeExit,
319
336
  onAnnotateModeToggle,
337
+ onToolsToggle,
320
338
  vimModeEnabled = false,
321
339
  vimHudEnabled = false,
322
340
  vimHudKeyPanelEnabled = true,
@@ -372,6 +390,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
372
390
  onAnnotateModeExitRef.current = onAnnotateModeExit;
373
391
  const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
374
392
  onAnnotateModeToggleRef.current = onAnnotateModeToggle;
393
+ const onToolsToggleRef = useRef(onToolsToggle);
394
+ onToolsToggleRef.current = onToolsToggle;
375
395
 
376
396
  /** Single choke point for direct-to-bridge posts: live sessions get the
377
397
  * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
@@ -569,6 +589,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
569
589
  onResize: handleResize,
570
590
  live: liveSession,
571
591
  onPageChange,
592
+ onLinkClick: onOpenLink,
572
593
  onBridgePointer: handleBridgePointer,
573
594
  onUnanchoredChange: handleBridgeUnanchored,
574
595
  maxAdditionalTargets,
@@ -697,6 +718,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
697
718
  onAnnotateModeToggleRef.current?.();
698
719
  return;
699
720
  }
721
+ if (isRecord(e.data) && e.data.type === `${PREFIX}tools-toggle`) {
722
+ onToolsToggleRef.current?.();
723
+ return;
724
+ }
700
725
  const vimCopy = parseVimBridgeCopy(e.data);
701
726
  if (vimCopy !== null) {
702
727
  const iframe = iframeRef.current;
@@ -798,6 +823,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
798
823
  postToBridge({ type: `${PREFIX}report-unanchored` });
799
824
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
800
825
 
826
+ // A linked document opened from a `#`-carrying link: the srcdoc document
827
+ // has no URL, so the fragment is replayed once its bridge is ready.
828
+ useEffect(() => {
829
+ if (iframeReadyVersion === 0 || !initialFragment) return;
830
+ postToBridge({ type: `${PREFIX}scroll-to-fragment`, fragment: initialFragment });
831
+ }, [iframeReadyVersion, initialFragment, postToBridge]);
832
+
801
833
  // Live page navigation with a ready iframe: explicitly clear the previous
802
834
  // page's marks, then re-apply the filtered set. Relying on dead anchors to
803
835
  // hide pins would risk cross-page text-search false matches and waste