birdclaw 0.8.1 → 0.8.3

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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
@@ -1,7 +1,9 @@
1
1
  import { Check, ChevronDown } from "lucide-react";
2
+ import { useQuery } from "@tanstack/react-query";
2
3
  import type { ReactNode } from "react";
3
4
  import { useEffect, useMemo, useRef, useState } from "react";
4
5
  import { fetchQueryEnvelope } from "#/lib/api-client";
6
+ import { queryKeys } from "#/lib/query-client";
5
7
  import type { AccountRecord } from "#/lib/types";
6
8
  import { cx } from "#/lib/ui";
7
9
  import { AvatarChip } from "./AvatarChip";
@@ -25,29 +27,19 @@ function avatarName(account: AccountRecord) {
25
27
  }
26
28
 
27
29
  export function AccountSwitcher({ action }: { action?: ReactNode } = {}) {
28
- const [accounts, setAccounts] = useState<AccountRecord[]>([]);
29
30
  const [open, setOpen] = useState(false);
30
31
  const switcherRef = useRef<HTMLDivElement>(null);
32
+ const statusQuery = useQuery({
33
+ queryKey: queryKeys.status,
34
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
35
+ });
36
+ const accounts = statusQuery.data?.accounts ?? [];
31
37
  const selectedAccountId = useSelectedAccountId(accounts);
32
38
  const selectedAccount = useMemo(
33
39
  () => accounts.find((account) => account.id === selectedAccountId),
34
40
  [accounts, selectedAccountId],
35
41
  );
36
42
 
37
- useEffect(() => {
38
- let active = true;
39
- fetchQueryEnvelope()
40
- .then((meta) => {
41
- if (active) setAccounts(meta.accounts);
42
- })
43
- .catch(() => {
44
- if (active) setAccounts([]);
45
- });
46
- return () => {
47
- active = false;
48
- };
49
- }, []);
50
-
51
43
  useEffect(() => {
52
44
  if (!open) return;
53
45
 
@@ -49,7 +49,7 @@ export function AvatarChip({
49
49
  );
50
50
  }
51
51
 
52
- function avatarPath(profileId: string, avatarUrl: string) {
52
+ export function avatarPath(profileId: string, avatarUrl: string) {
53
53
  const query = new URLSearchParams({
54
54
  profileId,
55
55
  v: avatarUrl,
@@ -0,0 +1,149 @@
1
+ import { type RefObject, useEffect } from "react";
2
+ import { avatarPath } from "./AvatarChip";
3
+
4
+ const MAX_CONCURRENT_PRELOADS = 4;
5
+ const IDLE_TIMEOUT_MS = 2500;
6
+ const PRELOAD_ROOT_MARGIN = "800px 0px";
7
+
8
+ const queuedSources: string[] = [];
9
+ const knownSources = new Set<string>();
10
+ const activeImages = new Set<HTMLImageElement>();
11
+
12
+ let activePreloads = 0;
13
+ let listeningForLoad = false;
14
+ let idleId: number | null = null;
15
+ let fallbackTimerId: number | null = null;
16
+
17
+ function finishPreload(image: HTMLImageElement) {
18
+ if (!activeImages.delete(image)) return;
19
+ activePreloads -= 1;
20
+ drainPreloads();
21
+ }
22
+
23
+ function drainPreloads() {
24
+ while (activePreloads < MAX_CONCURRENT_PRELOADS && queuedSources.length > 0) {
25
+ const src = queuedSources.shift();
26
+ if (!src) return;
27
+
28
+ const image = new Image();
29
+ activePreloads += 1;
30
+ activeImages.add(image);
31
+ image.decoding = "async";
32
+ image.onerror = () => finishPreload(image);
33
+ image.onload = () => {
34
+ if (typeof image.decode !== "function") {
35
+ finishPreload(image);
36
+ return;
37
+ }
38
+ void image
39
+ .decode()
40
+ .catch(() => undefined)
41
+ .finally(() => finishPreload(image));
42
+ };
43
+ image.src = src;
44
+ }
45
+ }
46
+
47
+ function runIdlePreloads() {
48
+ idleId = null;
49
+ fallbackTimerId = null;
50
+ drainPreloads();
51
+ }
52
+
53
+ function scheduleIdlePreloads() {
54
+ if (
55
+ queuedSources.length === 0 ||
56
+ idleId !== null ||
57
+ fallbackTimerId !== null
58
+ ) {
59
+ return;
60
+ }
61
+
62
+ const requestIdle = window.requestIdleCallback?.bind(window);
63
+ if (requestIdle) {
64
+ idleId = requestIdle(runIdlePreloads, {
65
+ timeout: IDLE_TIMEOUT_MS,
66
+ });
67
+ return;
68
+ }
69
+
70
+ fallbackTimerId = window.setTimeout(runIdlePreloads, 0);
71
+ }
72
+
73
+ function handleWindowLoad() {
74
+ listeningForLoad = false;
75
+ scheduleIdlePreloads();
76
+ }
77
+
78
+ function scheduleAfterPageLoad() {
79
+ if (document.readyState === "complete") {
80
+ scheduleIdlePreloads();
81
+ return;
82
+ }
83
+ if (listeningForLoad) return;
84
+ listeningForLoad = true;
85
+ window.addEventListener("load", handleWindowLoad, { once: true });
86
+ }
87
+
88
+ function queueAvatarPreload(profileId?: string, avatarUrl?: string) {
89
+ if (!profileId || !avatarUrl || typeof window === "undefined") return;
90
+ const src = avatarPath(profileId, avatarUrl);
91
+ if (knownSources.has(src)) return;
92
+ knownSources.add(src);
93
+ queuedSources.push(src);
94
+ scheduleAfterPageLoad();
95
+ }
96
+
97
+ export function useAvatarPreload(
98
+ reference: RefObject<Element | null>,
99
+ profileId?: string,
100
+ avatarUrl?: string,
101
+ ) {
102
+ useEffect(() => {
103
+ if (!profileId || !avatarUrl) return;
104
+ const node = reference.current;
105
+ if (!node || typeof IntersectionObserver === "undefined") {
106
+ queueAvatarPreload(profileId, avatarUrl);
107
+ return;
108
+ }
109
+
110
+ const observer = new IntersectionObserver(
111
+ (entries) => {
112
+ if (!entries.some((entry) => entry.isIntersecting)) return;
113
+ observer.disconnect();
114
+ queueAvatarPreload(profileId, avatarUrl);
115
+ },
116
+ { rootMargin: PRELOAD_ROOT_MARGIN },
117
+ );
118
+ observer.observe(node);
119
+ return () => observer.disconnect();
120
+ }, [avatarUrl, profileId, reference]);
121
+ }
122
+
123
+ function resetPreloader() {
124
+ if (listeningForLoad) {
125
+ window.removeEventListener("load", handleWindowLoad);
126
+ }
127
+ if (idleId !== null && "cancelIdleCallback" in window) {
128
+ window.cancelIdleCallback(idleId);
129
+ }
130
+ if (fallbackTimerId !== null) {
131
+ window.clearTimeout(fallbackTimerId);
132
+ }
133
+ for (const image of activeImages) {
134
+ image.onload = null;
135
+ image.onerror = null;
136
+ }
137
+ queuedSources.length = 0;
138
+ knownSources.clear();
139
+ activeImages.clear();
140
+ activePreloads = 0;
141
+ listeningForLoad = false;
142
+ idleId = null;
143
+ fallbackTimerId = null;
144
+ }
145
+
146
+ export const __test__ = {
147
+ queueAvatarPreload,
148
+ resetPreloader,
149
+ };
@@ -11,6 +11,7 @@ import { AvatarChip } from "./AvatarChip";
11
11
  import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
12
12
  import { ProfilePreview } from "./ProfilePreview";
13
13
  import { SmartTimestamp } from "./SmartTimestamp";
14
+ import { TweetArticleCard } from "./TweetArticleCard";
14
15
  import { TweetMediaGrid } from "./TweetMediaGrid";
15
16
  import { TweetRichText } from "./TweetRichText";
16
17
 
@@ -117,6 +118,9 @@ export function ConversationThread({
117
118
  text={tweet.text}
118
119
  />
119
120
  <TweetMediaGrid items={tweet.media} />
121
+ {tweet.entities.article ? (
122
+ <TweetArticleCard article={tweet.entities.article} />
123
+ ) : null}
120
124
  </div>
121
125
  </div>
122
126
  );
@@ -10,6 +10,7 @@ import {
10
10
  } from "#/lib/ui";
11
11
  import { ProfilePreview } from "./ProfilePreview";
12
12
  import { SmartTimestamp } from "./SmartTimestamp";
13
+ import { TweetArticleCard } from "./TweetArticleCard";
13
14
  import { TweetMediaGrid } from "./TweetMediaGrid";
14
15
  import { TweetRichText } from "./TweetRichText";
15
16
 
@@ -46,6 +47,9 @@ export function EmbeddedTweetCard({
46
47
  text={item.text}
47
48
  />
48
49
  <TweetMediaGrid items={item.media} />
50
+ {item.entities.article ? (
51
+ <TweetArticleCard article={item.entities.article} />
52
+ ) : null}
49
53
  </section>
50
54
  );
51
55
  }
@@ -0,0 +1,382 @@
1
+ import {
2
+ type CSSProperties,
3
+ type FocusEvent,
4
+ type KeyboardEvent,
5
+ type PointerEvent,
6
+ type RefObject,
7
+ useCallback,
8
+ useEffect,
9
+ useId,
10
+ useLayoutEffect,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+
15
+ type PreviewSide = "top" | "bottom";
16
+
17
+ type Rect = {
18
+ top: number;
19
+ right: number;
20
+ bottom: number;
21
+ left: number;
22
+ width: number;
23
+ height: number;
24
+ };
25
+
26
+ type PreviewPosition = {
27
+ top: number;
28
+ left: number;
29
+ maxWidth: number;
30
+ maxHeight: number;
31
+ side: PreviewSide;
32
+ measured: boolean;
33
+ ready: boolean;
34
+ };
35
+
36
+ type PlacementInput = {
37
+ referenceRects: Rect[];
38
+ floatingWidth: number;
39
+ floatingHeight: number;
40
+ viewport: Rect;
41
+ gap?: number;
42
+ gutter?: number;
43
+ };
44
+
45
+ const DEFAULT_GAP = 10;
46
+ const DEFAULT_GUTTER = 12;
47
+ const DEFAULT_CLOSE_DELAY_MS = 120;
48
+
49
+ const initialPosition: PreviewPosition = {
50
+ top: 0,
51
+ left: 0,
52
+ maxWidth: 0,
53
+ maxHeight: 0,
54
+ side: "bottom",
55
+ measured: false,
56
+ ready: false,
57
+ };
58
+
59
+ function edgeRect(rects: Rect[], side: PreviewSide) {
60
+ return rects.reduce((best, rect) => {
61
+ if (side === "bottom") {
62
+ if (rect.bottom > best.bottom) return rect;
63
+ if (rect.bottom === best.bottom && rect.width > best.width) return rect;
64
+ return best;
65
+ }
66
+ if (rect.top < best.top) return rect;
67
+ if (rect.top === best.top && rect.width > best.width) return rect;
68
+ return best;
69
+ });
70
+ }
71
+
72
+ function clamp(value: number, minimum: number, maximum: number) {
73
+ if (maximum < minimum) return minimum;
74
+ return Math.min(Math.max(value, minimum), maximum);
75
+ }
76
+
77
+ function calculatePreviewPosition({
78
+ referenceRects,
79
+ floatingWidth,
80
+ floatingHeight,
81
+ viewport,
82
+ gap = DEFAULT_GAP,
83
+ gutter = DEFAULT_GUTTER,
84
+ }: PlacementInput): PreviewPosition {
85
+ const referenceTop = Math.min(...referenceRects.map((rect) => rect.top));
86
+ const referenceBottom = Math.max(
87
+ ...referenceRects.map((rect) => rect.bottom),
88
+ );
89
+ const availableBelow = Math.max(
90
+ 0,
91
+ viewport.bottom - gutter - referenceBottom - gap,
92
+ );
93
+ const availableAbove = Math.max(
94
+ 0,
95
+ referenceTop - viewport.top - gutter - gap,
96
+ );
97
+ const side: PreviewSide =
98
+ availableBelow >= floatingHeight || availableBelow >= availableAbove
99
+ ? "bottom"
100
+ : "top";
101
+ const availableHeight = side === "bottom" ? availableBelow : availableAbove;
102
+ const renderedHeight = Math.min(floatingHeight, availableHeight);
103
+ const anchor = edgeRect(referenceRects, side);
104
+ const maxWidth = Math.max(0, viewport.width - gutter * 2);
105
+ const renderedWidth = Math.min(floatingWidth, maxWidth);
106
+ const minimumLeft = viewport.left + gutter;
107
+ const maximumLeft = viewport.right - gutter - renderedWidth;
108
+ const left = clamp(
109
+ anchor.left + anchor.width / 2 - renderedWidth / 2,
110
+ minimumLeft,
111
+ maximumLeft,
112
+ );
113
+ const top =
114
+ side === "bottom"
115
+ ? referenceBottom + gap
116
+ : referenceTop - gap - renderedHeight;
117
+
118
+ return {
119
+ top,
120
+ left,
121
+ maxWidth,
122
+ maxHeight: availableHeight,
123
+ side,
124
+ measured: true,
125
+ ready: true,
126
+ };
127
+ }
128
+
129
+ function viewportRect(): Rect {
130
+ const viewport = window.visualViewport;
131
+ const left = viewport?.offsetLeft ?? 0;
132
+ const top = viewport?.offsetTop ?? 0;
133
+ const width = viewport?.width ?? window.innerWidth;
134
+ const height = viewport?.height ?? window.innerHeight;
135
+ return {
136
+ top,
137
+ right: left + width,
138
+ bottom: top + height,
139
+ left,
140
+ width,
141
+ height,
142
+ };
143
+ }
144
+
145
+ function elementRects(element: HTMLElement): Rect[] {
146
+ const fragments = Array.from(element.getClientRects()).filter(
147
+ (rect) => rect.width > 0 || rect.height > 0,
148
+ );
149
+ return fragments.length > 0 ? fragments : [element.getBoundingClientRect()];
150
+ }
151
+
152
+ function referenceIntersectsViewport(rects: Rect[], viewport: Rect) {
153
+ return rects.some(
154
+ (rect) =>
155
+ rect.right >= viewport.left &&
156
+ rect.left <= viewport.right &&
157
+ rect.bottom >= viewport.top &&
158
+ rect.top <= viewport.bottom,
159
+ );
160
+ }
161
+
162
+ function samePosition(left: PreviewPosition, right: PreviewPosition) {
163
+ return (
164
+ left.top === right.top &&
165
+ left.left === right.left &&
166
+ left.maxWidth === right.maxWidth &&
167
+ left.maxHeight === right.maxHeight &&
168
+ left.side === right.side &&
169
+ left.measured === right.measured &&
170
+ left.ready === right.ready
171
+ );
172
+ }
173
+
174
+ function floatingStyle(position: PreviewPosition): CSSProperties {
175
+ return {
176
+ position: "fixed",
177
+ top: position.top,
178
+ left: position.left,
179
+ // Apply constraints during the hidden first pass so remeasurement can settle.
180
+ maxWidth: position.measured ? position.maxWidth : undefined,
181
+ maxHeight: position.measured ? position.maxHeight : undefined,
182
+ visibility: position.ready ? "visible" : "hidden",
183
+ };
184
+ }
185
+
186
+ function containsTarget(
187
+ target: EventTarget | null,
188
+ ...elements: Array<HTMLElement | null>
189
+ ) {
190
+ return (
191
+ target instanceof Node &&
192
+ elements.some((element) => element?.contains(target))
193
+ );
194
+ }
195
+
196
+ export function useFloatingPreview(options: { closeDelayMs?: number } = {}): {
197
+ open: boolean;
198
+ referenceRef: RefObject<HTMLSpanElement | null>;
199
+ floatingRef: RefObject<HTMLSpanElement | null>;
200
+ floatingStyle: CSSProperties;
201
+ referenceProps: {
202
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
203
+ onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
204
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
205
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
206
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
207
+ };
208
+ floatingProps: {
209
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
210
+ onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
211
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
212
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
213
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
214
+ };
215
+ floatingId: string;
216
+ closePreview: () => void;
217
+ } {
218
+ const closeDelayMs = options.closeDelayMs ?? DEFAULT_CLOSE_DELAY_MS;
219
+ const [open, setOpen] = useState(false);
220
+ const [position, setPosition] = useState(initialPosition);
221
+ const floatingId = useId();
222
+ const referenceRef = useRef<HTMLSpanElement | null>(null);
223
+ const floatingRef = useRef<HTMLSpanElement | null>(null);
224
+ const closeTimerRef = useRef<number | null>(null);
225
+
226
+ const clearCloseTimer = useCallback(() => {
227
+ if (closeTimerRef.current === null) return;
228
+ window.clearTimeout(closeTimerRef.current);
229
+ closeTimerRef.current = null;
230
+ }, []);
231
+
232
+ const openPreview = useCallback(() => {
233
+ clearCloseTimer();
234
+ if (!open) setPosition(initialPosition);
235
+ setOpen(true);
236
+ }, [clearCloseTimer, open]);
237
+
238
+ const closePreview = useCallback(() => {
239
+ clearCloseTimer();
240
+ setOpen(false);
241
+ }, [clearCloseTimer]);
242
+
243
+ const scheduleClose = useCallback(() => {
244
+ clearCloseTimer();
245
+ closeTimerRef.current = window.setTimeout(() => {
246
+ closeTimerRef.current = null;
247
+ const reference = referenceRef.current;
248
+ const floating = floatingRef.current;
249
+ const focusInside = containsTarget(
250
+ document.activeElement,
251
+ reference,
252
+ floating,
253
+ );
254
+ const pointerInside =
255
+ Boolean(reference?.matches(":hover")) ||
256
+ Boolean(floating?.matches(":hover"));
257
+ if (!focusInside && !pointerInside) setOpen(false);
258
+ }, closeDelayMs);
259
+ }, [clearCloseTimer, closeDelayMs]);
260
+
261
+ const handleBlur = useCallback(
262
+ (event: FocusEvent<HTMLElement>) => {
263
+ if (
264
+ containsTarget(
265
+ event.relatedTarget,
266
+ referenceRef.current,
267
+ floatingRef.current,
268
+ )
269
+ ) {
270
+ return;
271
+ }
272
+ scheduleClose();
273
+ },
274
+ [scheduleClose],
275
+ );
276
+
277
+ const handleKeyDown = useCallback(
278
+ (event: KeyboardEvent<HTMLElement>) => {
279
+ if (event.key !== "Escape") return;
280
+ closePreview();
281
+ referenceRef.current?.querySelector<HTMLElement>("a,button")?.focus();
282
+ },
283
+ [closePreview],
284
+ );
285
+
286
+ const updatePlacement = useCallback(() => {
287
+ const reference = referenceRef.current;
288
+ const floating = floatingRef.current;
289
+ if (!reference || !floating) return;
290
+ const viewport = viewportRect();
291
+ const referenceRects = elementRects(reference);
292
+ if (!referenceIntersectsViewport(referenceRects, viewport)) {
293
+ setPosition((current) =>
294
+ current.ready ? { ...current, ready: false } : current,
295
+ );
296
+ return;
297
+ }
298
+ const floatingRect = floating.getBoundingClientRect();
299
+ const content = floating.querySelector<HTMLElement>(
300
+ "[data-floating-preview-content]",
301
+ );
302
+ const style = window.getComputedStyle(floating);
303
+ const verticalChrome = [
304
+ style.paddingTop,
305
+ style.paddingBottom,
306
+ style.borderTopWidth,
307
+ style.borderBottomWidth,
308
+ ].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0);
309
+ // Measure the unclipped child so a previously constrained card can grow again.
310
+ const naturalHeight = Math.max(
311
+ floatingRect.height,
312
+ (content?.getBoundingClientRect().height ?? 0) + verticalChrome,
313
+ );
314
+ const next = calculatePreviewPosition({
315
+ referenceRects,
316
+ floatingWidth: floatingRect.width,
317
+ floatingHeight: naturalHeight,
318
+ viewport,
319
+ });
320
+ if (floatingRect.width > next.maxWidth + 0.5) next.ready = false;
321
+ setPosition((current) => (samePosition(current, next) ? current : next));
322
+ }, []);
323
+
324
+ useLayoutEffect(() => {
325
+ if (!open) return;
326
+ updatePlacement();
327
+ let frame = 0;
328
+ const trackLayout = () => {
329
+ updatePlacement();
330
+ frame = window.requestAnimationFrame(trackLayout);
331
+ };
332
+ // Fixed positioning needs explicit tracking when surrounding content shifts.
333
+ frame = window.requestAnimationFrame(trackLayout);
334
+ const viewport = window.visualViewport;
335
+ window.addEventListener("resize", updatePlacement);
336
+ window.addEventListener("scroll", updatePlacement, true);
337
+ viewport?.addEventListener("resize", updatePlacement);
338
+ viewport?.addEventListener("scroll", updatePlacement);
339
+ const observer =
340
+ typeof ResizeObserver === "undefined"
341
+ ? null
342
+ : new ResizeObserver(updatePlacement);
343
+ if (referenceRef.current) observer?.observe(referenceRef.current);
344
+ if (floatingRef.current) observer?.observe(floatingRef.current);
345
+
346
+ return () => {
347
+ window.cancelAnimationFrame(frame);
348
+ window.removeEventListener("resize", updatePlacement);
349
+ window.removeEventListener("scroll", updatePlacement, true);
350
+ viewport?.removeEventListener("resize", updatePlacement);
351
+ viewport?.removeEventListener("scroll", updatePlacement);
352
+ observer?.disconnect();
353
+ };
354
+ }, [open, updatePlacement]);
355
+
356
+ useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
357
+
358
+ const interactionProps = {
359
+ onPointerEnter: (_event: PointerEvent<HTMLElement>) => openPreview(),
360
+ onPointerLeave: (_event: PointerEvent<HTMLElement>) => scheduleClose(),
361
+ onFocus: (_event: FocusEvent<HTMLElement>) => openPreview(),
362
+ onBlur: handleBlur,
363
+ onKeyDown: handleKeyDown,
364
+ };
365
+
366
+ return {
367
+ open,
368
+ referenceRef,
369
+ floatingRef,
370
+ floatingStyle: floatingStyle(position),
371
+ referenceProps: interactionProps,
372
+ floatingProps: interactionProps,
373
+ floatingId,
374
+ closePreview,
375
+ };
376
+ }
377
+
378
+ export const __test__ = {
379
+ calculatePreviewPosition,
380
+ floatingStyle,
381
+ referenceIntersectsViewport,
382
+ };