@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,913 @@
1
+ /**
2
+ * Hover cards for issue, pull request and merge request links.
3
+ *
4
+ * Two parts, each usable alone:
5
+ *
6
+ * - `RefCardView`: the card's contents for one answer (loading, the item, or
7
+ * a quiet failure that still offers "Open on GitHub / GitLab").
8
+ * Presentational: no fetch, no state but an inline link confirm.
9
+ * - `useRefHoverCards(rootRef)`: one controller per region of rendered text.
10
+ * It listens on the region (event delegation), so links drawn anywhere
11
+ * inside (a `#123` that InlineMarkdown linked, a pasted PR or MR URL) get a
12
+ * card with no change to the renderer. It does nothing unless the host
13
+ * installed a transport (`utils/refCard`), and asks for data only once a
14
+ * hover or keyboard focus has rested on a link for `REF_CARD_DELAY_MS`.
15
+ *
16
+ * Behaviour: open on hover after the delay, or on keyboard focus (Enter on
17
+ * the link still follows it). Tab from the focused link moves into the card;
18
+ * Shift+Tab from its first control goes back. A press inside the card pins it
19
+ * (it then stays when the pointer leaves, and scrolls); Escape, Close or a
20
+ * press outside close it, focus back on the link when it was in the card.
21
+ * One card at a time. On a touch screen a tap on the link shows the card as a
22
+ * sheet from the bottom edge instead of following it; the sheet's "Open on
23
+ * GitHub" follows it. No keyboard shortcut of its own.
24
+ *
25
+ * Untrusted text: the body and the comment come from anyone who can write on
26
+ * the repo. They render through `RenderedMarkdown` (DOMPurify on HTML
27
+ * blocks), images are never loaded (drawn as their alt text), and a link
28
+ * inside them does nothing until the person confirms it in the card.
29
+ */
30
+
31
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
32
+ import { createPortal } from 'react-dom';
33
+ import {
34
+ forgeRefLabel,
35
+ type ForgeRef,
36
+ type ForgeRefCardData,
37
+ type ForgeRefCardError,
38
+ type ForgeRefCardResult,
39
+ type ForgeRefChecks,
40
+ type ForgeRefPerson,
41
+ } from '@plannotator/core/forge-ref-card';
42
+ import { loadRefCard, refCardTarget } from '../utils/refCard';
43
+ import { RenderedMarkdown } from './RenderedMarkdown';
44
+ import { tooltipContainerOf } from './HostTitle';
45
+
46
+ /** How long a hover or focus rests on a link before the card opens and its data is asked for. */
47
+ export const REF_CARD_DELAY_MS = 400;
48
+ /** How long the pointer may be away (link to card, card to link) before an unpinned card closes. */
49
+ export const REF_CARD_CLOSE_MS = 250;
50
+
51
+ const CARD_WIDTH = 400;
52
+ const EDGE = 12;
53
+ const GAP = 6;
54
+ /** The description box's tallest (border box), before it scrolls: about eight lines of a description. */
55
+ const BODY_MAX = 216;
56
+ /** The same once pinned: the person asked for it, so more of the description shows. */
57
+ const BODY_MAX_PINNED = 340;
58
+
59
+ // --- words ---
60
+
61
+ function timeAgo(iso: string, now = Date.now()): string {
62
+ const t = Date.parse(iso);
63
+ if (!Number.isFinite(t)) return '';
64
+ const s = Math.max(0, Math.round((now - t) / 1000));
65
+ if (s < 60) return 'just now';
66
+ const m = Math.round(s / 60);
67
+ if (m < 60) return `${m} min ago`;
68
+ const h = Math.round(m / 60);
69
+ if (h < 24) return `${h} h ago`;
70
+ const d = Math.round(h / 24);
71
+ if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`;
72
+ return new Date(t).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
73
+ }
74
+
75
+ const fmt = (n: number) => n.toLocaleString();
76
+ const forgeName = (p: ForgeRef['platform']) => (p === 'gitlab' ? 'GitLab' : 'GitHub');
77
+
78
+ /** "Issue", "Pull request", "Merge request". */
79
+ export function refKindName(ref: Pick<ForgeRef, 'platform' | 'kind'>): string {
80
+ return ref.kind === 'issue' ? 'Issue' : ref.platform === 'gitlab' ? 'Merge request' : 'Pull request';
81
+ }
82
+
83
+ const FAILURE_TEXT: Record<ForgeRefCardError, (forge: string, cli: string) => string> = {
84
+ unsupported: (_forge, cli) => `Details show for hosts your ${cli} is signed in to. This one isn't, so nothing was fetched.`,
85
+ cli_missing: (forge, cli) => `Install the ${forge} CLI (${cli}) on this computer to see details here.`,
86
+ signed_out: (_forge, cli) => `Sign in with ${cli} auth login to see details here.`,
87
+ not_found: (forge) => `None of your signed-in ${forge} accounts can see this one, or it doesn't exist.`,
88
+ rate_limited: (forge) => `${forge} asked for a short break. Details come back in a minute.`,
89
+ unavailable: () => "Details couldn't load right now.",
90
+ };
91
+
92
+ type Tone = 'open' | 'merged' | 'closed' | 'neutral';
93
+
94
+ function stateOf(data: ForgeRefCardData): { label: string; tone: Tone } {
95
+ switch (data.state) {
96
+ case 'open':
97
+ return { label: 'Open', tone: 'open' };
98
+ case 'draft':
99
+ return { label: 'Draft', tone: 'neutral' };
100
+ case 'merged':
101
+ return { label: 'Merged', tone: 'merged' };
102
+ case 'completed':
103
+ return { label: 'Closed', tone: 'merged' };
104
+ case 'not_planned':
105
+ return { label: 'Not planned', tone: 'neutral' };
106
+ default:
107
+ return { label: 'Closed', tone: data.kind === 'pull' ? 'closed' : 'neutral' };
108
+ }
109
+ }
110
+
111
+ // Merged and done read in the palette's primary (violet in Plannotator's own), like the forges' purple.
112
+ const TONE_TEXT: Record<Tone, string> = {
113
+ open: 'text-success',
114
+ merged: 'text-primary',
115
+ closed: 'text-destructive',
116
+ neutral: 'text-muted-foreground',
117
+ };
118
+ const TONE_PILL: Record<Tone, string> = {
119
+ open: 'bg-success/15 text-success',
120
+ merged: 'bg-primary/15 text-primary',
121
+ closed: 'bg-destructive/15 text-destructive',
122
+ neutral: 'bg-muted text-muted-foreground',
123
+ };
124
+
125
+ function StateIcon({ kind, tone, className = '' }: { kind: 'issue' | 'pull'; tone: Tone; className?: string }) {
126
+ const cls = `h-4 w-4 flex-none ${TONE_TEXT[tone]} ${className}`;
127
+ if (kind === 'issue') {
128
+ return (
129
+ <svg viewBox="0 0 16 16" className={cls} aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5">
130
+ <circle cx="8" cy="8" r="6.25" />
131
+ {tone === 'merged' ? (
132
+ <path d="M5.5 8.2l1.7 1.7 3.4-3.6" strokeLinecap="round" strokeLinejoin="round" />
133
+ ) : (
134
+ <circle cx="8" cy="8" r="1.4" fill="currentColor" stroke="none" />
135
+ )}
136
+ </svg>
137
+ );
138
+ }
139
+ return (
140
+ <svg viewBox="0 0 16 16" className={cls} aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
141
+ <circle cx="4.5" cy="3.5" r="1.6" />
142
+ <circle cx="4.5" cy="12.5" r="1.6" />
143
+ <circle cx="11.5" cy="12.5" r="1.6" />
144
+ <path d="M4.5 5.1v5.8" />
145
+ {tone === 'merged' ? <path d="M4.5 5.1c0 3.4 7 2.4 7 5.8" /> : <path d="M11.5 10.9V6.5a2 2 0 00-2-2H7.5m1.4-1.4L7.5 4.5l1.4 1.4" />}
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ function Avatar({ person, size = 18 }: { person: ForgeRefPerson; size?: number }) {
151
+ return person.avatarUrl ? (
152
+ <img src={person.avatarUrl} alt="" width={size} height={size} className="flex-none rounded-full" />
153
+ ) : (
154
+ <span
155
+ aria-hidden="true"
156
+ className="inline-flex flex-none items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-muted-foreground"
157
+ style={{ width: size, height: size }}
158
+ >
159
+ {person.login.slice(0, 1).toUpperCase()}
160
+ </span>
161
+ );
162
+ }
163
+
164
+ /** "All 32 checks passed" / "Pipeline failed · 2 of 12 jobs". */
165
+ export function checksText(checks: ForgeRefChecks, platform: ForgeRef['platform']): string {
166
+ if (platform === 'gitlab') {
167
+ const jobs = (n: number) => `${n} job${n === 1 ? '' : 's'}`;
168
+ if (checks.state === 'failure') return `Pipeline failed${checks.total ? ` · ${checks.failed} of ${jobs(checks.total)}` : ''}`;
169
+ if (checks.state === 'pending') return `Pipeline running${checks.total ? ` · ${checks.pending} of ${jobs(checks.total)} left` : ''}`;
170
+ return `Pipeline passed${checks.total ? ` · ${jobs(checks.total)}` : ''}`;
171
+ }
172
+ if (checks.failed > 0) return `${checks.failed} failing${checks.pending ? `, ${checks.pending} running` : ''} · ${checks.passed} passed`;
173
+ if (checks.pending > 0 || checks.state === 'pending') return `${checks.pending} running · ${checks.passed} passed`;
174
+ return `All ${checks.total} check${checks.total === 1 ? '' : 's'} passed`;
175
+ }
176
+
177
+ function ChecksLine({ checks, platform }: { checks: ForgeRefChecks; platform: ForgeRef['platform'] }) {
178
+ const failing = checks.state === 'failure' || checks.failed > 0;
179
+ const pending = !failing && (checks.state === 'pending' || checks.pending > 0);
180
+ const tone = failing ? 'text-destructive' : pending ? 'text-warning' : 'text-success';
181
+ return (
182
+ <span className={`inline-flex items-center gap-1 ${tone}`} data-ref-card-checks={failing ? 'failure' : pending ? 'pending' : 'success'}>
183
+ <span aria-hidden="true">{failing ? '✕' : pending ? '●' : '✓'}</span>
184
+ {checksText(checks, platform)}
185
+ </span>
186
+ );
187
+ }
188
+
189
+ const REVIEW_TEXT = {
190
+ approved: { text: 'Approved', cls: 'text-success' },
191
+ changes_requested: { text: 'Changes requested', cls: 'text-destructive' },
192
+ review_required: { text: 'Review required', cls: 'text-warning' },
193
+ } as const;
194
+
195
+ /** Body markdown with images and links made harmless (the renderer still sanitizes HTML). An empty title: the alt text says it, and no native tooltip shows. */
196
+ const NO_IMAGES = () => ({ kind: 'unavailable' as const, title: '' });
197
+
198
+ /** A local path in an untrusted body never opens anything of the host's (a thread's file, a document). */
199
+ const NO_LOCAL_LINKS = () => ({ kind: 'unavailable' as const, title: '' });
200
+
201
+ const XLINK = 'http://www.w3.org/1999/xlink';
202
+ /** Everything in a body that could navigate: HTML and SVG anchors, image-map areas. */
203
+ const LINKISH = 'a, area';
204
+
205
+ function linkHref(el: Element): string | null {
206
+ return el.getAttribute('href') ?? el.getAttributeNS(XLINK, 'href');
207
+ }
208
+
209
+ /** The link an event landed on, `href` still on it or already moved. */
210
+ function linkAt(root: Element | null, target: EventTarget | null): Element | null {
211
+ const el = target instanceof Element ? target.closest(LINKISH) : null;
212
+ if (!el || !root?.contains(el)) return null;
213
+ return el.hasAttribute('data-ref-card-href') || linkHref(el) !== null ? el : null;
214
+ }
215
+
216
+ /**
217
+ * Moves every link's `href` to `data-ref-card-href`, so nothing in the body can navigate: not a
218
+ * click, a middle-click, a modified click or the context menu's "Open link". Run after every change
219
+ * to the body's DOM, whoever made it: React, or `HtmlBlock`, which writes raw HTML in a passive
220
+ * effect after this component's layout effects ran.
221
+ */
222
+ function neutralizeLinks(root: Element): void {
223
+ for (const a of root.querySelectorAll(LINKISH)) {
224
+ const href = linkHref(a);
225
+ if (href === null) continue;
226
+ a.setAttribute('data-ref-card-href', href);
227
+ a.removeAttribute('href');
228
+ a.removeAttributeNS(XLINK, 'href');
229
+ a.removeAttribute('target');
230
+ a.setAttribute('role', 'link');
231
+ if (a instanceof HTMLElement || a instanceof SVGElement) {
232
+ a.tabIndex = 0;
233
+ a.style.cursor = 'pointer';
234
+ }
235
+ }
236
+ }
237
+
238
+ /** The longest path the confirm shows whole; a longer one keeps its start and end. */
239
+ const CONFIRM_PATH_MAX = 48;
240
+
241
+ /**
242
+ * A link's parts for the confirm row: the host whole (it says where the link really goes, so it is
243
+ * never shortened, however long a lookalike makes it), then the path, cut in the middle when long.
244
+ */
245
+ export function confirmUrlParts(url: string): { scheme: string; host: string; path: string } {
246
+ let u: URL;
247
+ try {
248
+ u = new URL(url);
249
+ } catch {
250
+ return { scheme: '', host: url, path: '' };
251
+ }
252
+ const rest = `${u.pathname === '/' && !u.search && !u.hash ? '' : u.pathname}${u.search}${u.hash}`;
253
+ const half = Math.floor((CONFIRM_PATH_MAX - 1) / 2);
254
+ const path = rest.length > CONFIRM_PATH_MAX ? `${rest.slice(0, half)}…${rest.slice(-half)}` : rest;
255
+ return { scheme: `${u.protocol}//`, host: u.host, path };
256
+ }
257
+
258
+ /** The confirm's URL: a screen reader gets it whole; the eye gets the host whole, in the text colour. */
259
+ function ConfirmUrl({ url }: { url: string }) {
260
+ const { scheme, host, path } = confirmUrlParts(url);
261
+ return (
262
+ <span className="min-w-0 flex-1 font-mono" data-ref-card-confirm-url="">
263
+ <span className="sr-only">{url}</span>
264
+ <span aria-hidden="true" className="[overflow-wrap:anywhere]">
265
+ <span className="text-muted-foreground">{scheme}</span>
266
+ <span className="font-semibold text-foreground" data-ref-card-confirm-host="">
267
+ {host}
268
+ </span>
269
+ <span className="text-muted-foreground" data-ref-card-confirm-path="">
270
+ {path}
271
+ </span>
272
+ </span>
273
+ </span>
274
+ );
275
+ }
276
+
277
+ /**
278
+ * Links in the body carry no `href` once drawn. A click with any button or modifier, or Enter, on
279
+ * one only offers "Open · Cancel" with its full URL; the Open there is the one real link. The
280
+ * listeners are native and in the capture phase on the body, so they run before any listener the
281
+ * renderer put on a link, and they catch a link whose `href` is still on it.
282
+ */
283
+ function SafeMarkdown({ markdown }: { markdown: string }) {
284
+ const [confirm, setConfirm] = useState<string | null>(null);
285
+ const bodyRef = useRef<HTMLDivElement | null>(null);
286
+ // Every render (React's own links), and on every later DOM change (raw HTML lands after this).
287
+ useLayoutEffect(() => {
288
+ if (bodyRef.current) neutralizeLinks(bodyRef.current);
289
+ });
290
+ useLayoutEffect(() => {
291
+ const body = bodyRef.current;
292
+ if (!body) return;
293
+ const ask = (e: Event) => {
294
+ const a = linkAt(body, e.target);
295
+ if (!a) return;
296
+ e.preventDefault();
297
+ e.stopPropagation();
298
+ if (e.type === 'mousedown') return;
299
+ const href = a.getAttribute('data-ref-card-href') ?? linkHref(a) ?? '';
300
+ setConfirm(/^https?:\/\//i.test(href) ? href : null);
301
+ };
302
+ const onKey = (e: KeyboardEvent) => {
303
+ if (e.key === 'Enter') ask(e);
304
+ };
305
+ const onMouseDown = (e: MouseEvent) => {
306
+ // The middle button's autoscroll and open-in-tab start on the press.
307
+ if (e.button === 1) ask(e);
308
+ };
309
+ body.addEventListener('click', ask, true);
310
+ body.addEventListener('auxclick', ask, true);
311
+ body.addEventListener('mousedown', onMouseDown, true);
312
+ body.addEventListener('keydown', onKey, true);
313
+ const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(() => neutralizeLinks(body));
314
+ observer?.observe(body, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
315
+ return () => {
316
+ body.removeEventListener('click', ask, true);
317
+ body.removeEventListener('auxclick', ask, true);
318
+ body.removeEventListener('mousedown', onMouseDown, true);
319
+ body.removeEventListener('keydown', onKey, true);
320
+ observer?.disconnect();
321
+ };
322
+ }, []);
323
+ return (
324
+ <div
325
+ // `!`: a host may rescale the renderer's fixed sizes in a later cascade layer (the Inbox's text size does).
326
+ className="text-[12.5px] leading-[1.55] text-foreground [overflow-wrap:anywhere] [&_h1]:my-1.5! [&_h1]:text-[13px]! [&_h1]:font-semibold [&_h2]:my-1.5! [&_h2]:text-[13px]! [&_h2]:font-semibold [&_h3]:my-1! [&_h3]:text-[12.5px]! [&_h3]:font-semibold [&_p]:my-1! [&_p]:text-[12.5px]! [&_p]:leading-[1.55]! [&_.text-sm]:text-[12.5px]! [&_pre]:text-[11.5px]! [&_.my-5]:my-2! [&_ul]:my-1! [&_li]:my-0! [&>:first-child]:mt-0!"
327
+ >
328
+ <div
329
+ ref={bodyRef}
330
+ >
331
+ <RenderedMarkdown markdown={markdown} resolveImage={NO_IMAGES} resolveLocalLink={NO_LOCAL_LINKS} />
332
+ </div>
333
+ {confirm ? (
334
+ <div className="mt-1.5 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2 py-1.5 text-[11.5px]" role="group" aria-label="Open this link?" data-ref-card-confirm="">
335
+ <ConfirmUrl url={confirm} />
336
+ <a href={confirm} target="_blank" rel="noopener noreferrer nofollow" className="font-medium text-primary hover:underline" onClick={() => setConfirm(null)}>
337
+ Open
338
+ </a>
339
+ <button type="button" className="text-muted-foreground hover:text-foreground" onClick={() => setConfirm(null)}>
340
+ Cancel
341
+ </button>
342
+ </div>
343
+ ) : null}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ // --- the card ---
349
+
350
+ export interface RefCardViewProps {
351
+ target: ForgeRef;
352
+ href: string;
353
+ /** undefined: still loading. null: no answer. */
354
+ result: ForgeRefCardResult | null | undefined;
355
+ pinned?: boolean;
356
+ onClose?: () => void;
357
+ /** The sheet on a touch screen (the body takes the height, larger targets). */
358
+ sheet?: boolean;
359
+ /**
360
+ * Too little room for the whole card (a short window): the header and the description scroll
361
+ * together, so the description never shrinks to a line, and the footer (Open on GitHub) stays.
362
+ */
363
+ tight?: boolean;
364
+ }
365
+
366
+ export function RefCardView({ target, href, result, pinned, onClose, sheet, tight }: RefCardViewProps) {
367
+ const repoName = `${target.owner}/${target.repo}`;
368
+ const forge = forgeName(target.platform);
369
+ const btn = sheet ? 'min-h-11 px-3' : 'h-7 px-2.5';
370
+ const footer = (extra?: React.ReactNode) => (
371
+ <div className="flex flex-none items-center gap-2 border-t border-border px-3.5 py-2 text-[11.5px] text-muted-foreground" data-ref-card-foot="">
372
+ <span className="min-w-0 flex-1 truncate">{extra}</span>
373
+ {onClose && (pinned || sheet) ? (
374
+ <button type="button" onClick={onClose} className={`${btn} rounded-md hover:bg-muted hover:text-foreground`} data-ref-card-close="">
375
+ Close
376
+ </button>
377
+ ) : null}
378
+ <a
379
+ href={result?.ok ? result.data.url : href}
380
+ target="_blank"
381
+ rel="noopener noreferrer"
382
+ className={`${btn} inline-flex items-center gap-1 rounded-md bg-primary/10 font-medium text-primary hover:bg-primary/15`}
383
+ data-ref-card-open=""
384
+ >
385
+ Open on {forge} <span aria-hidden="true">↗</span>
386
+ </a>
387
+ </div>
388
+ );
389
+
390
+ if (result === undefined) {
391
+ return (
392
+ <div aria-busy="true" data-ref-card-loading="">
393
+ <div className="px-3.5 pb-3 pt-3">
394
+ <div className="text-[11.5px] text-muted-foreground">
395
+ {repoName} · {forgeRefLabel(target)}
396
+ </div>
397
+ <div className="mt-2 h-4 w-4/5 animate-pulse rounded bg-muted" />
398
+ <div className="mt-2 h-3 w-2/5 animate-pulse rounded bg-muted" />
399
+ <div className="mt-3 h-3 w-full animate-pulse rounded bg-muted/70" />
400
+ <div className="mt-1.5 h-3 w-11/12 animate-pulse rounded bg-muted/70" />
401
+ </div>
402
+ {footer()}
403
+ </div>
404
+ );
405
+ }
406
+
407
+ if (!result || !result.ok) {
408
+ const error = result && 'error' in result ? result.error : 'unavailable';
409
+ return (
410
+ <div>
411
+ <div className="px-3.5 pb-3 pt-3">
412
+ <div className="text-[11.5px] text-muted-foreground">{repoName}</div>
413
+ <div className="mt-1 text-[13.5px] font-semibold text-foreground">
414
+ {refKindName(target)} {forgeRefLabel(target)}
415
+ </div>
416
+ <p className="mt-1.5 text-[12px] leading-relaxed text-muted-foreground" data-ref-card-error={error}>
417
+ {FAILURE_TEXT[error](forge, target.platform === 'gitlab' ? 'glab' : 'gh')}
418
+ </p>
419
+ </div>
420
+ {footer()}
421
+ </div>
422
+ );
423
+ }
424
+
425
+ const d = result.data;
426
+ const state = stateOf(d);
427
+ const ref = { platform: d.platform, kind: d.kind, number: d.number };
428
+ const p = d.pull;
429
+ // One element scrolls: the description (its own height capped), or, when tight, the header and the description together.
430
+ const together = !!tight && !sheet;
431
+ const scroller = { 'data-ref-card-scroll': '', tabIndex: 0 };
432
+ return (
433
+ <div className="flex min-h-0 flex-1 flex-col">
434
+ <div
435
+ className={together ? 'min-h-0 flex-1 overflow-y-auto overscroll-contain' : 'flex min-h-0 flex-1 flex-col'}
436
+ {...(together ? { ...scroller, 'aria-label': `${refKindName(ref)} details` } : {})}
437
+ >
438
+ <div className="flex-none px-3.5 pt-3" data-ref-card-head="">
439
+ <div className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
440
+ <span className="truncate">{d.repo || repoName}</span>
441
+ <span aria-hidden="true">·</span>
442
+ <span className="flex-none">opened {timeAgo(d.createdAt)}</span>
443
+ </div>
444
+ <div className="mt-1.5 flex items-start gap-2">
445
+ <StateIcon kind={d.kind} tone={state.tone} className="mt-[3px]" />
446
+ <div className="min-w-0 flex-1 text-[14px] font-semibold leading-snug text-foreground [overflow-wrap:anywhere]" data-ref-card-title="">
447
+ {d.title} <span className="font-normal text-muted-foreground">{forgeRefLabel(ref)}</span>
448
+ </div>
449
+ </div>
450
+ <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground">
451
+ <span className={`rounded-full px-2 py-[1px] text-[11px] font-semibold ${TONE_PILL[state.tone]}`} data-ref-card-state={d.state}>
452
+ {state.label}
453
+ </span>
454
+ {d.author ? (
455
+ <span className="inline-flex items-center gap-1">
456
+ <Avatar person={d.author} size={16} />
457
+ {d.author.login}
458
+ </span>
459
+ ) : null}
460
+ {d.closedAt && d.state !== 'open' && d.state !== 'draft' ? (
461
+ <span>
462
+ {d.state === 'merged' ? 'merged' : 'closed'} {timeAgo(d.closedAt)}
463
+ </span>
464
+ ) : (
465
+ <span>updated {timeAgo(d.updatedAt)}</span>
466
+ )}
467
+ </div>
468
+ {p ? (
469
+ <div className="mt-2 space-y-1 text-[11.5px]">
470
+ {p.base || p.head ? (
471
+ <div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" data-ref-card-branch="">
472
+ {p.base} ← {p.head}
473
+ </div>
474
+ ) : null}
475
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
476
+ <span>
477
+ <span className="text-success">+{fmt(p.additions)}</span> <span className="text-destructive">−{fmt(p.deletions)}</span>
478
+ <span className="text-muted-foreground">
479
+ {' '}
480
+ · {fmt(p.files)} file{p.files === 1 ? '' : 's'}
481
+ </span>
482
+ </span>
483
+ {p.checks ? <ChecksLine checks={p.checks} platform={d.platform} /> : null}
484
+ {p.review ? (
485
+ <span className={REVIEW_TEXT[p.review].cls} data-ref-card-review={p.review}>
486
+ {REVIEW_TEXT[p.review].text}
487
+ </span>
488
+ ) : null}
489
+ </div>
490
+ </div>
491
+ ) : null}
492
+ {d.labels.length > 0 || d.assignees.length > 0 ? (
493
+ <div className="mt-2 flex flex-wrap items-center gap-1">
494
+ {d.labels.map((l) => (
495
+ <span key={l.name} className="inline-flex max-w-full items-center gap-1 rounded-full border border-border px-1.5 py-[1px] text-[10.5px] text-foreground">
496
+ <span aria-hidden="true" className="h-2 w-2 flex-none rounded-full" style={{ background: `#${l.color}` }} />
497
+ <span className="truncate">{l.name}</span>
498
+ </span>
499
+ ))}
500
+ {d.assignees.length > 0 ? (
501
+ <span className="ml-auto inline-flex items-center gap-1" role="img" aria-label={`Assigned to ${d.assignees.map((a) => a.login).join(', ')}`}>
502
+ {d.assignees.map((a) => (
503
+ <Avatar key={a.login} person={a} size={16} />
504
+ ))}
505
+ </span>
506
+ ) : null}
507
+ </div>
508
+ ) : null}
509
+ </div>
510
+
511
+ <div
512
+ className={`mt-2.5 border-t border-border px-3.5 py-2.5 ${together ? '' : 'min-h-0 flex-1 overflow-y-auto overscroll-contain'}`}
513
+ style={sheet || together ? undefined : { maxHeight: pinned ? BODY_MAX_PINNED : BODY_MAX }}
514
+ data-ref-card-body=""
515
+ {...(together ? {} : { ...scroller, 'aria-label': `${refKindName(ref)} description` })}
516
+ >
517
+ {d.body.trim() ? <SafeMarkdown markdown={d.body} /> : <p className="text-[12px] italic text-muted-foreground">No description.</p>}
518
+ {d.bodyCut ? <p className="mt-1 text-[11px] text-muted-foreground">The rest is on {forge}.</p> : null}
519
+ {d.linked.length > 0 ? (
520
+ <div className="mt-2.5 space-y-1">
521
+ <div className="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{d.kind === 'pull' ? 'Closes' : 'Closed by'}</div>
522
+ {d.linked.map((l) => (
523
+ <div key={l.number} className="flex items-center gap-1.5 text-[12px]">
524
+ <StateIcon kind={d.kind === 'pull' ? 'issue' : 'pull'} tone={l.state === 'open' ? 'open' : l.state === 'merged' || d.kind === 'pull' ? 'merged' : 'closed'} />
525
+ <span className="min-w-0 truncate">{l.title}</span>
526
+ <span className="flex-none text-muted-foreground">#{l.number}</span>
527
+ </div>
528
+ ))}
529
+ </div>
530
+ ) : null}
531
+ {d.lastComment ? (
532
+ <div className="mt-2.5 rounded-md border border-border bg-muted/30 px-2.5 py-2" data-ref-card-comment="">
533
+ <div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
534
+ {d.lastComment.author ? <Avatar person={d.lastComment.author} size={14} /> : null}
535
+ <span className="font-medium text-foreground">{d.lastComment.author?.login ?? 'someone'}</span>
536
+ <span>commented {timeAgo(d.lastComment.at)}</span>
537
+ <span className="ml-auto">latest</span>
538
+ </div>
539
+ <SafeMarkdown markdown={d.lastComment.body} />
540
+ </div>
541
+ ) : null}
542
+ </div>
543
+ </div>
544
+ {footer(
545
+ <span className="inline-flex items-center gap-1">
546
+ <span aria-hidden="true">💬</span> {d.comments} comment{d.comments === 1 ? '' : 's'}
547
+ {pinned && !sheet ? <span className="ml-1.5 text-muted-foreground/80">· pinned</span> : null}
548
+ </span>,
549
+ )}
550
+ </div>
551
+ );
552
+ }
553
+
554
+ // --- the controller ---
555
+
556
+ interface OpenCard {
557
+ anchor: HTMLAnchorElement;
558
+ href: string;
559
+ target: ForgeRef;
560
+ via: 'hover' | 'focus' | 'tap';
561
+ pinned: boolean;
562
+ result: ForgeRefCardResult | null | undefined;
563
+ }
564
+
565
+ /** The card open anywhere on the page closes when another opens. */
566
+ let closeOther: (() => void) | null = null;
567
+
568
+ function touchOnly(): boolean {
569
+ return typeof window !== 'undefined' && !!window.matchMedia?.('(hover: none)').matches;
570
+ }
571
+
572
+ function firstControl(card: HTMLElement | null): HTMLElement | null {
573
+ return card?.querySelector<HTMLElement>('a[href], button, [tabindex="0"]') ?? null;
574
+ }
575
+
576
+ /**
577
+ * The card's height with nothing squeezed (the header, the description up to its cap, the footer)
578
+ * and the description's part of it. Measured from the parts, so it reads the same whether the card
579
+ * is tight or not (a tight card's description is uncapped, and its scroller clips the rest).
580
+ */
581
+ function naturalHeight(card: HTMLElement, pinned: boolean): { natural: number; body: number } {
582
+ const head = card.querySelector<HTMLElement>('[data-ref-card-head]');
583
+ const body = card.querySelector<HTMLElement>('[data-ref-card-body]');
584
+ const foot = card.querySelector<HTMLElement>('[data-ref-card-foot]');
585
+ if (!head || !body || !foot) return { natural: card.scrollHeight, body: 0 };
586
+ const cs = getComputedStyle(body);
587
+ const bodyHeight = Math.min(body.scrollHeight + (parseFloat(cs.borderTopWidth) || 0), pinned ? BODY_MAX_PINNED : BODY_MAX);
588
+ return {
589
+ natural: head.offsetHeight + (parseFloat(cs.marginTop) || 0) + bodyHeight + foot.offsetHeight + (card.offsetHeight - card.clientHeight),
590
+ body: bodyHeight,
591
+ };
592
+ }
593
+
594
+ function place(anchor: Element, card: HTMLElement, pinned: boolean): Pos {
595
+ const r = anchor.getBoundingClientRect();
596
+ const vw = window.innerWidth;
597
+ const vh = window.innerHeight;
598
+ const width = Math.min(CARD_WIDTH, vw - EDGE * 2);
599
+ const left = Math.min(Math.max(EDGE, r.left), vw - width - EDGE);
600
+ const below = vh - r.bottom - GAP - EDGE;
601
+ const above = r.top - GAP - EDGE;
602
+ const { natural, body } = naturalHeight(card, pinned);
603
+ const downward = below >= Math.min(natural, 320) || below >= above;
604
+ const maxHeight = Math.max(160, Math.min(520, downward ? below : above));
605
+ const height = Math.min(natural, maxHeight);
606
+ // Tight: the room left for the description is less than it shows unpinned (a pinned card that only
607
+ // lost some of its extra room keeps its header still, and its description scrolls as before).
608
+ const roomForBody = maxHeight - (natural - body);
609
+ const tight = natural > maxHeight + 1 && roomForBody < Math.min(body, BODY_MAX) - 1;
610
+ return { left, top: downward ? r.bottom + GAP : r.top - GAP - height, maxHeight, tight };
611
+ }
612
+
613
+ interface Pos {
614
+ left: number;
615
+ top: number;
616
+ maxHeight: number;
617
+ /** Too little room for the card's description: see `RefCardViewProps.tight`. */
618
+ tight: boolean;
619
+ }
620
+
621
+ /**
622
+ * Hover cards for the issue / PR / MR links inside `rootRef`. Render what it
623
+ * returns anywhere in the tree (it portals). Nothing happens without a transport.
624
+ *
625
+ * `onlyWithData` (default false): the card opens only once its data arrived
626
+ * and only when the forge answered (`ok`), with no loading state and no
627
+ * failure card; a hover that leaves first shows nothing. A tap on a touch
628
+ * screen follows the link as it always did (no sheet). For hosts where a
629
+ * person without gh / glab must see links exactly as before (plan review,
630
+ * annotate and code review).
631
+ */
632
+ export function useRefHoverCards(
633
+ rootRef: React.RefObject<HTMLElement | null>,
634
+ { delayMs = REF_CARD_DELAY_MS, onlyWithData = false }: { delayMs?: number; onlyWithData?: boolean } = {},
635
+ ): React.ReactNode {
636
+ const [card, setCard] = useState<OpenCard | null>(null);
637
+ /** onlyWithData: the link a card is being fetched for (cleared when the hover or focus leaves it). */
638
+ const wanted = useRef<HTMLAnchorElement | null>(null);
639
+ const dataOnly = useRef(onlyWithData);
640
+ dataOnly.current = onlyWithData;
641
+ const [pos, setPos] = useState<Pos | null>(null);
642
+ const cardRef = useRef<HTMLDivElement | null>(null);
643
+ const current = useRef<OpenCard | null>(null);
644
+ current.current = card;
645
+ const openTimer = useRef<number | null>(null);
646
+ const closeTimer = useRef<number | null>(null);
647
+ const lastPointer = useRef('mouse');
648
+
649
+ const clearTimers = () => {
650
+ if (openTimer.current !== null) window.clearTimeout(openTimer.current);
651
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
652
+ openTimer.current = closeTimer.current = null;
653
+ };
654
+
655
+ const close = useCallback((restoreFocus = false) => {
656
+ clearTimers();
657
+ const was = current.current;
658
+ if (!was) return;
659
+ const focusWasInside = !!cardRef.current?.contains(document.activeElement);
660
+ setCard(null);
661
+ if (restoreFocus && (focusWasInside || was.via === 'tap')) was.anchor.focus({ preventScroll: true });
662
+ }, []);
663
+ const closeSelf = useRef(() => close());
664
+ closeSelf.current = () => close();
665
+
666
+ const open = useCallback((anchor: HTMLAnchorElement, target: ForgeRef, via: OpenCard['via']) => {
667
+ clearTimers();
668
+ if (dataOnly.current) {
669
+ // Nothing on screen until the forge answered; then only if the hover or focus is still on the link.
670
+ wanted.current = anchor;
671
+ const href = anchor.href;
672
+ void loadRefCard(href, target).then((result) => {
673
+ if (wanted.current !== anchor) return;
674
+ wanted.current = null;
675
+ if (!result?.ok || !anchor.isConnected) return;
676
+ if (closeOther && closeOther !== closeSelf.current) closeOther();
677
+ closeOther = closeSelf.current;
678
+ setCard({ anchor, href, target, via, pinned: false, result });
679
+ });
680
+ return;
681
+ }
682
+ if (closeOther && closeOther !== closeSelf.current) closeOther();
683
+ closeOther = closeSelf.current;
684
+ const href = anchor.href;
685
+ setCard({ anchor, href, target, via, pinned: via === 'tap', result: undefined });
686
+ void loadRefCard(href, target).then((result) => {
687
+ setCard((c) => (c && c.anchor === anchor ? { ...c, result } : c));
688
+ });
689
+ }, []);
690
+
691
+ const scheduleClose = useCallback(() => {
692
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
693
+ closeTimer.current = window.setTimeout(() => {
694
+ if (!current.current?.pinned) close();
695
+ }, REF_CARD_CLOSE_MS);
696
+ }, [close]);
697
+
698
+ // Delegated listeners on the region.
699
+ useEffect(() => {
700
+ const root = rootRef.current;
701
+ if (!root) return;
702
+ const linkOf = (t: EventTarget | null) => (t instanceof Element ? t.closest<HTMLAnchorElement>('a[href]') : null);
703
+ const targetOf = (a: HTMLAnchorElement | null) => (a && root.contains(a) ? refCardTarget(a.href) : null);
704
+ const later = (fn: () => void) => {
705
+ if (openTimer.current !== null) window.clearTimeout(openTimer.current);
706
+ openTimer.current = window.setTimeout(fn, delayMs);
707
+ };
708
+ const onPointerDown = (e: PointerEvent) => {
709
+ lastPointer.current = e.pointerType;
710
+ };
711
+ const onOver = (e: PointerEvent) => {
712
+ if (e.pointerType === 'touch' || touchOnly()) return;
713
+ const a = linkOf(e.target);
714
+ const target = targetOf(a);
715
+ if (!a || !target) return;
716
+ if (current.current?.anchor === a) {
717
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
718
+ return;
719
+ }
720
+ later(() => open(a, target, 'hover'));
721
+ };
722
+ const onOut = (e: PointerEvent) => {
723
+ const a = linkOf(e.target);
724
+ if (!a || (e.relatedTarget instanceof Node && a.contains(e.relatedTarget))) return;
725
+ if (wanted.current === a) wanted.current = null;
726
+ if (openTimer.current !== null && current.current?.anchor !== a) {
727
+ window.clearTimeout(openTimer.current);
728
+ openTimer.current = null;
729
+ }
730
+ if (current.current?.anchor === a && !(e.relatedTarget instanceof Node && cardRef.current?.contains(e.relatedTarget))) scheduleClose();
731
+ };
732
+ const onFocusIn = (e: FocusEvent) => {
733
+ const a = linkOf(e.target);
734
+ const target = targetOf(a);
735
+ if (!a || !target || !a.matches(':focus-visible') || current.current?.anchor === a) return;
736
+ later(() => open(a, target, 'focus'));
737
+ };
738
+ const onFocusOut = (e: FocusEvent) => {
739
+ const a = linkOf(e.target);
740
+ if (!a) return;
741
+ if (openTimer.current !== null) window.clearTimeout(openTimer.current);
742
+ if (wanted.current === a) wanted.current = null;
743
+ const c = current.current;
744
+ const next = e.relatedTarget;
745
+ if (c?.anchor === a && c.via === 'focus' && !c.pinned && !(next instanceof Node && cardRef.current?.contains(next))) close();
746
+ };
747
+ const onKey = (e: KeyboardEvent) => {
748
+ const c = current.current;
749
+ if (!c || e.target !== c.anchor || e.key !== 'Tab' || e.shiftKey) return;
750
+ const first = firstControl(cardRef.current);
751
+ if (!first) return;
752
+ e.preventDefault();
753
+ setCard((x) => (x ? { ...x, pinned: true } : x));
754
+ first.focus();
755
+ };
756
+ const onClick = (e: MouseEvent) => {
757
+ // onlyWithData: a tap follows the link as it always did.
758
+ if (dataOnly.current) return;
759
+ if (lastPointer.current !== 'touch' && !touchOnly()) return;
760
+ const a = linkOf(e.target);
761
+ const target = targetOf(a);
762
+ if (!a || !target) return;
763
+ e.preventDefault();
764
+ e.stopPropagation();
765
+ open(a, target, 'tap');
766
+ };
767
+ root.addEventListener('pointerdown', onPointerDown, true);
768
+ root.addEventListener('pointerover', onOver);
769
+ root.addEventListener('pointerout', onOut);
770
+ root.addEventListener('focusin', onFocusIn);
771
+ root.addEventListener('focusout', onFocusOut);
772
+ root.addEventListener('keydown', onKey);
773
+ root.addEventListener('click', onClick, true);
774
+ return () => {
775
+ root.removeEventListener('pointerdown', onPointerDown, true);
776
+ root.removeEventListener('pointerover', onOver);
777
+ root.removeEventListener('pointerout', onOut);
778
+ root.removeEventListener('focusin', onFocusIn);
779
+ root.removeEventListener('focusout', onFocusOut);
780
+ root.removeEventListener('keydown', onKey);
781
+ root.removeEventListener('click', onClick, true);
782
+ clearTimers();
783
+ };
784
+ }, [rootRef, delayMs, open, close, scheduleClose]);
785
+
786
+ const reposition = useCallback(() => {
787
+ const c = current.current;
788
+ if (!c || !cardRef.current || c.via === 'tap') return;
789
+ setPos(place(c.anchor, cardRef.current, c.pinned));
790
+ }, []);
791
+
792
+ // While open: Escape, a press outside, scrolling and resizing.
793
+ useEffect(() => {
794
+ if (!card) return;
795
+ const onKey = (e: KeyboardEvent) => {
796
+ if (e.key !== 'Escape') return;
797
+ // Used: the window's own Escape (closing a thread) skips it.
798
+ e.preventDefault();
799
+ e.stopPropagation();
800
+ close(true);
801
+ };
802
+ const onDown = (e: PointerEvent) => {
803
+ const t = e.target as Node;
804
+ if (cardRef.current?.contains(t) || card.anchor.contains(t)) return;
805
+ // The sheet's scrim closes on its click: closing on pointer-down would let the tap land on what is under it.
806
+ if (t instanceof Element && t.closest('[data-ref-card-scrim]')) return;
807
+ close();
808
+ };
809
+ const onScroll = (e: Event) => {
810
+ if (card.via === 'tap' || cardRef.current?.contains(e.target as Node)) return;
811
+ if (!current.current?.pinned) close();
812
+ else reposition();
813
+ };
814
+ window.addEventListener('keydown', onKey, true);
815
+ window.addEventListener('pointerdown', onDown, true);
816
+ window.addEventListener('scroll', onScroll, true);
817
+ window.addEventListener('resize', reposition);
818
+ return () => {
819
+ window.removeEventListener('keydown', onKey, true);
820
+ window.removeEventListener('pointerdown', onDown, true);
821
+ window.removeEventListener('scroll', onScroll, true);
822
+ window.removeEventListener('resize', reposition);
823
+ };
824
+ }, [card, close, reposition]);
825
+
826
+ useEffect(
827
+ () => () => {
828
+ if (closeOther === closeSelf.current) closeOther = null;
829
+ },
830
+ [],
831
+ );
832
+
833
+ useLayoutEffect(() => {
834
+ if (!card) setPos(null);
835
+ else reposition();
836
+ }, [card?.anchor, card?.result, card?.pinned, reposition]);
837
+
838
+ if (!card || typeof document === 'undefined') return null;
839
+ const container = tooltipContainerOf(card.anchor) ?? document.body;
840
+ const sheet = card.via === 'tap';
841
+ const label = card.result?.ok
842
+ ? `${refKindName(card.result.data)} ${forgeRefLabel(card.result.data)}: ${card.result.data.title}`
843
+ : `${refKindName(card.target)} ${forgeRefLabel(card.target)}`;
844
+ const surface = 'border border-border bg-popover text-popover-foreground shadow-2xl';
845
+ const body = (
846
+ <div
847
+ ref={cardRef}
848
+ role="dialog"
849
+ aria-label={label}
850
+ aria-modal={sheet ? true : undefined}
851
+ data-ref-card=""
852
+ data-ref-card-pinned={card.pinned ? '' : undefined}
853
+ data-ref-card-sheet={sheet ? '' : undefined}
854
+ data-ref-card-tight={!sheet && pos?.tight ? '' : undefined}
855
+ onPointerEnter={() => {
856
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
857
+ }}
858
+ onPointerLeave={(e) => {
859
+ if (e.pointerType !== 'touch' && !card.pinned) scheduleClose();
860
+ }}
861
+ onPointerDown={() => {
862
+ if (!card.pinned) setCard((c) => (c ? { ...c, pinned: true } : c));
863
+ }}
864
+ onKeyDown={(e) => {
865
+ if (e.key === 'Tab' && e.shiftKey && document.activeElement === firstControl(cardRef.current)) {
866
+ e.preventDefault();
867
+ card.anchor.focus();
868
+ }
869
+ }}
870
+ className={
871
+ sheet
872
+ ? `fixed inset-x-0 bottom-0 z-[120] flex max-h-[78vh] flex-col overflow-hidden rounded-t-[12px] pb-[env(safe-area-inset-bottom)] ${surface}`
873
+ : `fixed z-[120] flex flex-col overflow-hidden rounded-xl ${surface} ${card.pinned ? 'ring-1 ring-primary/30' : ''}`
874
+ }
875
+ style={
876
+ sheet
877
+ ? undefined
878
+ : { width: Math.min(CARD_WIDTH, window.innerWidth - EDGE * 2), left: pos?.left ?? -9999, top: pos?.top ?? -9999, maxHeight: pos?.maxHeight ?? 520 }
879
+ }
880
+ >
881
+ {sheet ? <div aria-hidden="true" className="mx-auto mt-2 h-1 w-9 flex-none rounded-full bg-muted-foreground/30" /> : null}
882
+ <RefCardView target={card.target} href={card.href} result={card.result} pinned={card.pinned} sheet={sheet} tight={!sheet && !!pos?.tight} onClose={() => close(true)} />
883
+ </div>
884
+ );
885
+ return createPortal(
886
+ sheet ? (
887
+ <>
888
+ <div className="fixed inset-0 z-[119] bg-black/30" data-ref-card-scrim="" onClick={() => close(true)} />
889
+ {body}
890
+ </>
891
+ ) : (
892
+ body
893
+ ),
894
+ container,
895
+ );
896
+ }
897
+
898
+ /**
899
+ * `useRefHoverCards` as a component: render it inside the region `rootRef`
900
+ * points at, so its listeners attach once that element exists (a host whose
901
+ * root is not on screen at its first render, behind a loading state).
902
+ */
903
+ export function RefHoverCards({
904
+ rootRef,
905
+ delayMs,
906
+ onlyWithData,
907
+ }: {
908
+ rootRef: React.RefObject<HTMLElement | null>;
909
+ delayMs?: number;
910
+ onlyWithData?: boolean;
911
+ }): React.ReactElement {
912
+ return <>{useRefHoverCards(rootRef, { delayMs, onlyWithData })}</>;
913
+ }