birdclaw 0.8.1 → 0.8.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.8.2 - 2026-06-15
4
+
5
+ ### Fixed
6
+
7
+ - Wait for concurrent SQLite writers before starting write transactions and avoid unnecessary legacy backfill writes during normal startup.
8
+ - Retain timeline and DM data across sidebar navigation, deduplicate status reads, debounce searches, skip unused Spotlight archive scans in web status, and remove TanStack debug controls.
9
+ - Keep tweet and profile hover previews outside wrapped links, flip them to the roomier vertical side, and constrain them to the viewport.
10
+ - Expand Twitter Articles into titled preview cards and citation popovers instead of showing bare `t.co` links.
11
+
3
12
  ## 0.8.1 - 2026-06-13
4
13
 
5
14
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
@@ -54,10 +54,7 @@
54
54
  "dependencies": {
55
55
  "@steipete/sweet-cookie": "^0.4.0",
56
56
  "@tailwindcss/vite": "^4.3.0",
57
- "@tanstack/devtools-vite": "0.7.0",
58
- "@tanstack/react-devtools": "0.10.5",
59
57
  "@tanstack/react-router": "1.170.15",
60
- "@tanstack/react-router-devtools": "1.167.0",
61
58
  "@tanstack/react-router-ssr-query": "1.167.1",
62
59
  "@tanstack/react-start": "1.168.25",
63
60
  "@tanstack/router-plugin": "^1.168.18",
@@ -18,6 +18,33 @@ const SCENARIOS = {
18
18
  path: "/mentions",
19
19
  ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
20
20
  },
21
+ "sidebar-roundtrip": {
22
+ path: "/",
23
+ ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
24
+ action: async (page) => {
25
+ await page.getByRole("link", { name: "Mentions" }).click();
26
+ await page.waitForURL("**/mentions");
27
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
28
+ const queryCallsBeforeReturn = await page.evaluate(
29
+ () =>
30
+ performance
31
+ .getEntriesByType("resource")
32
+ .filter((entry) => entry.name.includes("/api/query")).length,
33
+ );
34
+ await page.getByRole("link", { name: "Home" }).click();
35
+ await page.waitForURL((url) => url.pathname === "/");
36
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
37
+ const queryCallsAfterReturn = await page.evaluate(
38
+ () =>
39
+ performance
40
+ .getEntriesByType("resource")
41
+ .filter((entry) => entry.name.includes("/api/query")).length,
42
+ );
43
+ if (queryCallsAfterReturn !== queryCallsBeforeReturn) {
44
+ throw new Error("Returning Home issued a new query request");
45
+ }
46
+ },
47
+ },
21
48
  "mentions-search": {
22
49
  path: "/mentions",
23
50
  ready: async (page) => {
@@ -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
+ };
@@ -1,11 +1,4 @@
1
- import {
2
- Fragment,
3
- type MouseEventHandler,
4
- type ReactNode,
5
- useLayoutEffect,
6
- useRef,
7
- useState,
8
- } from "react";
1
+ import { Fragment, type MouseEventHandler, type ReactNode } from "react";
9
2
  import { formatCompactNumber } from "#/lib/present";
10
3
  import type { PeriodDigestContext } from "#/lib/period-digest";
11
4
  import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
@@ -14,35 +7,17 @@ import type { ProfileRecord } from "#/lib/types";
14
7
  import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
15
8
  import { safeHttpUrl } from "#/lib/url-safety";
16
9
  import { AvatarChip } from "./AvatarChip";
10
+ import { useFloatingPreview } from "./FloatingPreview";
17
11
  import { ProfilePreview } from "./ProfilePreview";
18
12
  import { SmartTimestamp } from "./SmartTimestamp";
19
13
 
20
14
  type CitationTweet = PeriodDigestContext["tweets"][number];
21
15
  type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
22
- type VerticalBounds = { top: number; bottom: number };
23
-
24
16
  type InlineLookup = {
25
17
  tweetsById: Map<string, CitationTweet>;
26
18
  profilesByHandle: Map<string, ProfileRecord>;
27
19
  };
28
20
 
29
- function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
30
- let top = 0;
31
- let bottom = window.innerHeight;
32
- for (
33
- let current = element.parentElement;
34
- current;
35
- current = current.parentElement
36
- ) {
37
- const style = window.getComputedStyle(current);
38
- if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
39
- const rect = current.getBoundingClientRect();
40
- top = Math.max(top, rect.top);
41
- bottom = Math.min(bottom, rect.bottom);
42
- }
43
- return { top, bottom };
44
- }
45
-
46
21
  function normalizeTweetReference(value: string) {
47
22
  return value
48
23
  .trim()
@@ -135,13 +110,16 @@ function TweetSourceLink({
135
110
  children,
136
111
  href,
137
112
  onClick,
113
+ describedBy,
138
114
  }: {
139
115
  children: ReactNode;
140
116
  href: string;
141
117
  onClick?: MouseEventHandler<HTMLAnchorElement>;
118
+ describedBy?: string;
142
119
  }) {
143
120
  return (
144
121
  <a
122
+ aria-describedby={describedBy}
145
123
  className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
146
124
  href={href}
147
125
  onClick={onClick}
@@ -160,93 +138,73 @@ function TweetPreviewToken({
160
138
  tweet: CitationTweet;
161
139
  children: ReactNode;
162
140
  }) {
163
- const [open, setOpen] = useState(false);
164
- const [placeAbove, setPlaceAbove] = useState(false);
165
- const shellRef = useRef<HTMLSpanElement | null>(null);
166
- const cardRef = useRef<HTMLSpanElement | null>(null);
167
-
168
- useLayoutEffect(() => {
169
- if (!open) return;
170
- const updatePlacement = () => {
171
- const shell = shellRef.current;
172
- if (!shell) return;
173
- const shellRect = shell.getBoundingClientRect();
174
- const card = cardRef.current;
175
- const cardRect = card?.getBoundingClientRect();
176
- const cardHeight = Math.max(
177
- card?.offsetHeight ?? 0,
178
- cardRect?.height ?? 0,
179
- 220,
180
- );
181
- const bounds = nearestVerticalClipBounds(shell);
182
- const belowSpace = bounds.bottom - shellRect.bottom;
183
- const aboveSpace = shellRect.top - bounds.top;
184
- setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
185
- };
186
- updatePlacement();
187
- const frame = window.requestAnimationFrame(updatePlacement);
188
- return () => window.cancelAnimationFrame(frame);
189
- }, [open]);
190
-
191
- function closePreview() {
192
- setOpen(false);
193
- }
194
-
195
- const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
141
+ const preview = useFloatingPreview();
142
+
143
+ const article = tweet.entities?.article;
144
+ const renderedText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
145
+ const previewText = article
146
+ ? [article.title, article.previewText]
147
+ .filter(
148
+ (value, index, values): value is string =>
149
+ Boolean(value) && values.indexOf(value) === index,
150
+ )
151
+ .join("\n\n")
152
+ : renderedText;
196
153
 
197
154
  return (
198
155
  <span
199
- ref={shellRef}
200
- className="relative inline align-baseline"
201
- onBlur={closePreview}
202
- onFocus={() => setOpen(true)}
203
- onPointerEnter={() => setOpen(true)}
204
- onPointerLeave={closePreview}
156
+ ref={preview.referenceRef}
157
+ className="inline align-baseline"
158
+ {...preview.referenceProps}
205
159
  >
206
160
  <TweetSourceLink
161
+ describedBy={preview.open ? preview.floatingId : undefined}
207
162
  href={getTweetUrl(tweet)}
208
163
  onClick={(event) => {
209
- closePreview();
164
+ preview.closePreview();
210
165
  event.currentTarget.blur();
211
166
  }}
212
167
  >
213
168
  {children}
214
169
  </TweetSourceLink>
215
- <span
216
- ref={cardRef}
217
- aria-hidden={!open}
218
- className={cx(
219
- "absolute left-1/2 z-40 w-[360px] -translate-x-1/2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]",
220
- placeAbove ? "bottom-[calc(100%+10px)]" : "top-[calc(100%+10px)]",
221
- open ? "block" : "hidden",
222
- )}
223
- >
224
- <span className="mb-2 flex items-center gap-2">
225
- <AvatarChip
226
- avatarUrl={tweet.authorProfile.avatarUrl}
227
- hue={tweet.authorProfile.avatarHue}
228
- name={tweet.name}
229
- profileId={tweet.authorProfile.id}
230
- size="small"
231
- />
232
- <span className="min-w-0">
233
- <span className="block truncate font-bold">{tweet.name}</span>
234
- <span className="block truncate text-[12px] text-[var(--ink-soft)]">
235
- @{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
170
+ {preview.open ? (
171
+ <span
172
+ id={preview.floatingId}
173
+ ref={preview.floatingRef}
174
+ className="fixed z-40 w-[360px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]"
175
+ role="tooltip"
176
+ style={preview.floatingStyle}
177
+ {...preview.floatingProps}
178
+ >
179
+ <span className="block" data-floating-preview-content>
180
+ <span className="mb-2 flex items-center gap-2">
181
+ <AvatarChip
182
+ avatarUrl={tweet.authorProfile.avatarUrl}
183
+ hue={tweet.authorProfile.avatarHue}
184
+ name={tweet.name}
185
+ profileId={tweet.authorProfile.id}
186
+ size="small"
187
+ />
188
+ <span className="min-w-0">
189
+ <span className="block truncate font-bold">{tweet.name}</span>
190
+ <span className="block truncate text-[12px] text-[var(--ink-soft)]">
191
+ @{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
192
+ </span>
193
+ </span>
194
+ </span>
195
+ <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
196
+ {previewText}
197
+ </span>
198
+ <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
199
+ <span>{tweet.source}</span>
200
+ {tweet.likeCount > 0 ? (
201
+ <span>{formatCompactNumber(tweet.likeCount)} likes</span>
202
+ ) : null}
203
+ {tweet.needsReply ? <span>reply open</span> : null}
236
204
  </span>
237
205
  </span>
238
206
  </span>
239
- <span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
240
- {previewText}
241
- </span>
242
- <span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
243
- <span>{tweet.source}</span>
244
- {tweet.likeCount > 0 ? (
245
- <span>{formatCompactNumber(tweet.likeCount)} likes</span>
246
- ) : null}
247
- {tweet.needsReply ? <span>reply open</span> : null}
248
- </span>
249
- </span>
207
+ ) : null}
250
208
  </span>
251
209
  );
252
210
  }