birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,441 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { CheckCircle2, Loader2, RefreshCw, Sparkles } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { MarkdownViewer } from "#/components/MarkdownViewer";
5
+ import type {
6
+ PeriodDigestContext,
7
+ PeriodDigestRunResult,
8
+ PeriodDigestStreamEvent,
9
+ } from "#/lib/period-digest";
10
+ import type { ProfileRecord } from "#/lib/types";
11
+ import {
12
+ cx,
13
+ errorCopyClass,
14
+ pageHeaderActionsClass,
15
+ pageHeaderClass,
16
+ pageHeaderRowClass,
17
+ pageSubtitleClass,
18
+ pageTitleClass,
19
+ secondaryButtonClass,
20
+ segmentActiveClass,
21
+ segmentClass,
22
+ segmentedClass,
23
+ } from "#/lib/ui";
24
+
25
+ export const Route = createFileRoute("/today")({
26
+ component: TodayRoute,
27
+ });
28
+
29
+ type PeriodOption = "today" | "24h" | "yesterday" | "week";
30
+ type HydrateProfileResult = {
31
+ handle: string;
32
+ status: "hit" | "miss" | "error";
33
+ profile?: ProfileRecord;
34
+ };
35
+
36
+ const PROFILE_HYDRATION_LIMIT = 12;
37
+ const PROFILE_HYDRATION_DELAY_MS = 300;
38
+
39
+ const periods: Array<{ value: PeriodOption; label: string }> = [
40
+ { value: "today", label: "Today" },
41
+ { value: "24h", label: "24h" },
42
+ { value: "yesterday", label: "Yesterday" },
43
+ { value: "week", label: "Week" },
44
+ ];
45
+
46
+ function digestUrl(
47
+ period: PeriodOption,
48
+ includeDms: boolean,
49
+ refresh: boolean,
50
+ ) {
51
+ const url = new URL("/api/period-digest", window.location.origin);
52
+ url.searchParams.set("period", period);
53
+ url.searchParams.set("includeDms", String(includeDms));
54
+ if (refresh) {
55
+ url.searchParams.set("refresh", "true");
56
+ }
57
+ return url;
58
+ }
59
+
60
+ async function digestRequestError(response: Response) {
61
+ const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
62
+ let detail = "";
63
+ try {
64
+ const contentType = response.headers.get("content-type") ?? "";
65
+ if (contentType.includes("application/json")) {
66
+ const payload = (await response.json()) as {
67
+ error?: unknown;
68
+ message?: unknown;
69
+ };
70
+ if (typeof payload.message === "string") detail = payload.message;
71
+ else if (typeof payload.error === "string") detail = payload.error;
72
+ } else {
73
+ detail = (await response.text()).trim();
74
+ }
75
+ } catch {
76
+ detail = "";
77
+ }
78
+ return new Error(
79
+ detail
80
+ ? `Digest request failed (${status}): ${detail}`
81
+ : `Digest request failed (${status})`,
82
+ );
83
+ }
84
+
85
+ function formatCounts(context: PeriodDigestContext | null) {
86
+ if (!context) return "Local Twitter memory, summarized as it streams.";
87
+ const counts = context.counts;
88
+ return [
89
+ `${String(counts.home)} home`,
90
+ `${String(counts.mentions)} mentions`,
91
+ `${String(counts.links)} links`,
92
+ context.includeDms ? `${String(counts.dms)} DMs` : null,
93
+ ]
94
+ .filter(Boolean)
95
+ .join(" · ");
96
+ }
97
+
98
+ function normalizeHandle(value: string) {
99
+ return value.trim().replace(/^@/, "").toLowerCase();
100
+ }
101
+
102
+ function collectProfilesForHydration(result: PeriodDigestRunResult) {
103
+ const handles = new Set<string>();
104
+ const tweetIds = new Set<string>();
105
+ for (const id of result.digest.sourceTweetIds) tweetIds.add(id);
106
+ for (const topic of result.digest.keyTopics) {
107
+ for (const id of topic.tweetIds) tweetIds.add(id);
108
+ }
109
+ for (const link of result.digest.notableLinks) {
110
+ for (const id of link.sourceTweetIds) tweetIds.add(id);
111
+ }
112
+ for (const item of result.digest.actionItems) {
113
+ if (item.tweetId) tweetIds.add(item.tweetId);
114
+ }
115
+
116
+ const tweetsById = new Map(
117
+ result.context.tweets.flatMap((tweet) => [
118
+ [tweet.id, tweet],
119
+ [`tweet_${tweet.id}`, tweet],
120
+ ]),
121
+ );
122
+ for (const id of tweetIds) {
123
+ const tweet = tweetsById.get(id);
124
+ if (!tweet) continue;
125
+ const handle = normalizeHandle(tweet.author);
126
+ if (handle) handles.add(handle);
127
+ if (handles.size >= PROFILE_HYDRATION_LIMIT) return [...handles];
128
+ }
129
+
130
+ for (const tweet of result.context.tweets) {
131
+ const handle = normalizeHandle(tweet.author);
132
+ if (handle) handles.add(handle);
133
+ if (handles.size >= PROFILE_HYDRATION_LIMIT) return [...handles];
134
+ }
135
+ return [...handles];
136
+ }
137
+
138
+ function applyHydratedProfilesToContext(
139
+ context: PeriodDigestContext,
140
+ profilesByHandle: Map<string, ProfileRecord>,
141
+ ) {
142
+ let changed = false;
143
+ const tweets = context.tweets.map((tweet) => {
144
+ const profile = profilesByHandle.get(normalizeHandle(tweet.author));
145
+ if (!profile || profile === tweet.authorProfile) return tweet;
146
+ changed = true;
147
+ return {
148
+ ...tweet,
149
+ author: profile.handle,
150
+ name: profile.displayName,
151
+ authorProfile: profile,
152
+ };
153
+ });
154
+ return changed ? { ...context, tweets } : context;
155
+ }
156
+
157
+ function applyHydratedProfilesToResult(
158
+ result: PeriodDigestRunResult,
159
+ profiles: ProfileRecord[],
160
+ ) {
161
+ const profilesByHandle = new Map(
162
+ profiles.map((profile) => [normalizeHandle(profile.handle), profile]),
163
+ );
164
+ if (profilesByHandle.size === 0) return result;
165
+ const context = applyHydratedProfilesToContext(
166
+ result.context,
167
+ profilesByHandle,
168
+ );
169
+ return context === result.context ? result : { ...result, context };
170
+ }
171
+
172
+ function useDigestStream(period: PeriodOption, includeDms: boolean) {
173
+ const [markdown, setMarkdown] = useState("");
174
+ const [context, setContext] = useState<PeriodDigestContext | null>(null);
175
+ const [result, setResult] = useState<PeriodDigestRunResult | null>(null);
176
+ const [error, setError] = useState<string | null>(null);
177
+ const [loading, setLoading] = useState(false);
178
+ const abortRef = useRef<AbortController | null>(null);
179
+ const requestIdRef = useRef(0);
180
+ const hydratedHandlesRef = useRef(new Set<string>());
181
+ const hydratedProfilesRef = useRef(new Map<string, ProfileRecord>());
182
+
183
+ const run = useCallback(
184
+ (refresh = false) => {
185
+ abortRef.current?.abort();
186
+ const controller = new AbortController();
187
+ const requestId = requestIdRef.current + 1;
188
+ requestIdRef.current = requestId;
189
+ abortRef.current = controller;
190
+ const isActiveRequest = () =>
191
+ abortRef.current === controller &&
192
+ requestIdRef.current === requestId &&
193
+ !controller.signal.aborted;
194
+ setMarkdown("");
195
+ setContext(null);
196
+ setResult(null);
197
+ setError(null);
198
+ setLoading(true);
199
+
200
+ fetch(digestUrl(period, includeDms, refresh), {
201
+ signal: controller.signal,
202
+ })
203
+ .then(async (response) => {
204
+ if (!response.ok) {
205
+ throw await digestRequestError(response);
206
+ }
207
+ if (!response.body) {
208
+ throw new Error("Digest request failed: empty response body");
209
+ }
210
+ const reader = response.body.getReader();
211
+ const decoder = new TextDecoder();
212
+ let buffer = "";
213
+ const pump = (): Promise<void> =>
214
+ reader.read().then(({ done, value }) => {
215
+ if (!isActiveRequest()) return;
216
+ if (done) return;
217
+ buffer += decoder.decode(value, { stream: true });
218
+ let newline = buffer.indexOf("\n");
219
+ while (newline >= 0) {
220
+ const line = buffer.slice(0, newline).trim();
221
+ buffer = buffer.slice(newline + 1);
222
+ if (line) {
223
+ const event = JSON.parse(line) as PeriodDigestStreamEvent;
224
+ if (!isActiveRequest()) return;
225
+ if (event.type === "start") {
226
+ setContext(event.context);
227
+ } else if (event.type === "delta") {
228
+ setMarkdown((current) => current + event.delta);
229
+ } else if (event.type === "done") {
230
+ setResult(event.result);
231
+ setContext(event.result.context);
232
+ setMarkdown(event.result.markdown);
233
+ } else if (event.type === "error") {
234
+ setError(event.error);
235
+ }
236
+ }
237
+ newline = buffer.indexOf("\n");
238
+ }
239
+ return pump();
240
+ });
241
+ return pump();
242
+ })
243
+ .catch((cause: unknown) => {
244
+ if (!isActiveRequest()) return;
245
+ setError(cause instanceof Error ? cause.message : "Digest failed");
246
+ })
247
+ .finally(() => {
248
+ if (isActiveRequest()) {
249
+ setLoading(false);
250
+ }
251
+ });
252
+ },
253
+ [includeDms, period],
254
+ );
255
+
256
+ useEffect(() => {
257
+ run(false);
258
+ return () => abortRef.current?.abort();
259
+ }, [run]);
260
+
261
+ useEffect(() => {
262
+ if (!result) return;
263
+ if (hydratedProfilesRef.current.size > 0) {
264
+ const cachedProfiles = [...hydratedProfilesRef.current.values()];
265
+ setResult((current) =>
266
+ current
267
+ ? applyHydratedProfilesToResult(current, cachedProfiles)
268
+ : current,
269
+ );
270
+ setContext((current) =>
271
+ current
272
+ ? applyHydratedProfilesToContext(current, hydratedProfilesRef.current)
273
+ : current,
274
+ );
275
+ }
276
+ const handles = collectProfilesForHydration(result).filter(
277
+ (handle) => !hydratedHandlesRef.current.has(handle),
278
+ );
279
+ if (handles.length === 0) return;
280
+
281
+ const controller = new AbortController();
282
+ const url = new URL("/api/profile-hydrate", window.location.origin);
283
+ url.searchParams.set("handles", handles.join(","));
284
+
285
+ let idleId: number | null = null;
286
+ const runHydration = () => {
287
+ fetch(url, { signal: controller.signal })
288
+ .then((response) => response.json())
289
+ .then((response: { results?: HydrateProfileResult[] }) => {
290
+ for (const handle of handles) hydratedHandlesRef.current.add(handle);
291
+ const profiles =
292
+ response.results
293
+ ?.map((item) => item.profile)
294
+ .filter((profile): profile is ProfileRecord =>
295
+ Boolean(profile),
296
+ ) ?? [];
297
+ if (profiles.length === 0) return;
298
+ for (const profile of profiles) {
299
+ hydratedProfilesRef.current.set(
300
+ normalizeHandle(profile.handle),
301
+ profile,
302
+ );
303
+ }
304
+ setResult((current) =>
305
+ current
306
+ ? applyHydratedProfilesToResult(current, profiles)
307
+ : current,
308
+ );
309
+ const profilesByHandle = new Map(
310
+ profiles.map((profile) => [
311
+ normalizeHandle(profile.handle),
312
+ profile,
313
+ ]),
314
+ );
315
+ setContext((current) =>
316
+ current
317
+ ? applyHydratedProfilesToContext(current, profilesByHandle)
318
+ : current,
319
+ );
320
+ })
321
+ .catch((error: unknown) => {
322
+ if (error instanceof DOMException && error.name === "AbortError") {
323
+ return;
324
+ }
325
+ console.warn("Profile hydration failed", error);
326
+ });
327
+ };
328
+ const timer = window.setTimeout(() => {
329
+ if ("requestIdleCallback" in window) {
330
+ idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
331
+ } else {
332
+ runHydration();
333
+ }
334
+ }, PROFILE_HYDRATION_DELAY_MS);
335
+
336
+ return () => {
337
+ controller.abort();
338
+ window.clearTimeout(timer);
339
+ if (idleId !== null && "cancelIdleCallback" in window) {
340
+ window.cancelIdleCallback(idleId);
341
+ }
342
+ };
343
+ }, [result]);
344
+
345
+ return { context, error, loading, markdown, result, run };
346
+ }
347
+
348
+ function TodayRoute() {
349
+ const [period, setPeriod] = useState<PeriodOption>("today");
350
+ const [includeDms, setIncludeDms] = useState(false);
351
+ const { context, error, loading, markdown, result, run } = useDigestStream(
352
+ period,
353
+ includeDms,
354
+ );
355
+ const sourceLabel = useMemo(
356
+ () => formatCounts(result?.context ?? context),
357
+ [context, result],
358
+ );
359
+
360
+ return (
361
+ <div className="flex min-h-screen flex-col">
362
+ <header className={pageHeaderClass}>
363
+ <div className={pageHeaderRowClass}>
364
+ <div className="min-w-0">
365
+ <h1 className={pageTitleClass}>What happened</h1>
366
+ <p className={pageSubtitleClass}>{sourceLabel}</p>
367
+ </div>
368
+ <div className={pageHeaderActionsClass}>
369
+ <button
370
+ type="button"
371
+ className={secondaryButtonClass}
372
+ onClick={() => run(true)}
373
+ disabled={loading}
374
+ >
375
+ <RefreshCw
376
+ className={cx("size-4", loading && "animate-spin")}
377
+ aria-hidden="true"
378
+ />
379
+ Refresh
380
+ </button>
381
+ </div>
382
+ </div>
383
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
384
+ <div className={segmentedClass} aria-label="Digest period">
385
+ {periods.map((item) => (
386
+ <button
387
+ key={item.value}
388
+ type="button"
389
+ className={cx(
390
+ segmentClass,
391
+ period === item.value && segmentActiveClass,
392
+ )}
393
+ onClick={() => setPeriod(item.value)}
394
+ >
395
+ {item.label}
396
+ </button>
397
+ ))}
398
+ </div>
399
+ <label className="inline-flex items-center gap-2 rounded-full border border-[var(--line)] px-3 py-1 text-[13px] font-medium text-[var(--ink-soft)]">
400
+ <input
401
+ type="checkbox"
402
+ checked={includeDms}
403
+ onChange={(event) => setIncludeDms(event.currentTarget.checked)}
404
+ />
405
+ DMs
406
+ </label>
407
+ </div>
408
+ </header>
409
+
410
+ {error ? <div className={errorCopyClass}>{error}</div> : null}
411
+
412
+ <div className="border-b border-[var(--line)] px-4 py-2 text-[13px] text-[var(--ink-soft)]">
413
+ <span className="inline-flex items-center gap-1">
414
+ {loading ? (
415
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
416
+ ) : markdown ? (
417
+ <CheckCircle2 className="size-4" aria-hidden="true" />
418
+ ) : (
419
+ <Sparkles className="size-4" aria-hidden="true" />
420
+ )}
421
+ {loading
422
+ ? "Streaming report"
423
+ : result
424
+ ? `${result.cached ? "Cached" : "Ready"} · ${result.context.window.label}`
425
+ : "Ready"}
426
+ </span>
427
+ </div>
428
+
429
+ {markdown ? (
430
+ <MarkdownViewer
431
+ context={result?.context ?? context}
432
+ markdown={markdown}
433
+ />
434
+ ) : (
435
+ <div className="px-4 py-5 text-[14px] text-[var(--ink-soft)]">
436
+ Waiting for the first tokens...
437
+ </div>
438
+ )}
439
+ </div>
440
+ );
441
+ }
package/src/styles.css CHANGED
@@ -34,23 +34,24 @@
34
34
  --ring: rgb(29 155 240 / 35%);
35
35
  --shadow: rgb(15 20 25 / 6%);
36
36
  --shadow-strong: rgb(15 20 25 / 12%);
37
+ --brand-shadow: rgb(29 155 240 / 22%);
37
38
  --theme-switch-x: 50%;
38
39
  --theme-switch-y: 50%;
39
40
  color-scheme: light;
40
41
  }
41
42
 
42
43
  [data-theme="dark"] {
43
- --bg: #000000;
44
- --bg-elevated: #16181c;
45
- --bg-hover: #16181c;
46
- --bg-active: #1d1f23;
47
- --panel: #000000;
48
- --panel-strong: #16181c;
49
- --line: #2f3336;
50
- --line-strong: #536471;
51
- --ink: #e7e9ea;
52
- --ink-soft: #71767b;
53
- --ink-mute: #5b6166;
44
+ --bg: #0b0f14;
45
+ --bg-elevated: #10151b;
46
+ --bg-hover: #141a21;
47
+ --bg-active: #18212a;
48
+ --panel: #0b0f14;
49
+ --panel-strong: #121820;
50
+ --line: #26323d;
51
+ --line-strong: #44515d;
52
+ --ink: #d8dee5;
53
+ --ink-soft: #8b98a5;
54
+ --ink-mute: #687480;
54
55
  --accent: #1d9bf0;
55
56
  --accent-hover: #1a8cd8;
56
57
  --accent-press: #177cc1;
@@ -63,6 +64,7 @@
63
64
  --ring: rgb(29 155 240 / 45%);
64
65
  --shadow: rgb(0 0 0 / 60%);
65
66
  --shadow-strong: rgb(0 0 0 / 80%);
67
+ --brand-shadow: rgb(29 155 240 / 36%);
66
68
  color-scheme: dark;
67
69
  }
68
70
 
@@ -141,6 +143,62 @@ textarea {
141
143
  }
142
144
  }
143
145
 
146
+ @keyframes birdclaw-load-sway {
147
+ 0%,
148
+ 100% {
149
+ transform: translateY(0) rotate(-2deg) scale(1);
150
+ }
151
+
152
+ 40% {
153
+ transform: translateY(-5px) rotate(3deg) scale(1.04);
154
+ }
155
+
156
+ 70% {
157
+ transform: translateY(1px) rotate(-1deg) scale(0.99);
158
+ }
159
+ }
160
+
161
+ @keyframes birdclaw-load-glow {
162
+ 0%,
163
+ 100% {
164
+ opacity: 0.24;
165
+ transform: scale(0.82);
166
+ }
167
+
168
+ 50% {
169
+ opacity: 0.46;
170
+ transform: scale(1.08);
171
+ }
172
+ }
173
+
174
+ .birdclaw-state {
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ justify-content: center;
179
+ min-height: 168px;
180
+ }
181
+
182
+ .birdclaw-mark::before {
183
+ content: "";
184
+ position: absolute;
185
+ inset: 16%;
186
+ z-index: -1;
187
+ border-radius: 999px;
188
+ background: var(--accent-soft);
189
+ filter: blur(14px);
190
+ opacity: 0.34;
191
+ }
192
+
193
+ .birdclaw-mark-animated {
194
+ animation: birdclaw-load-sway 1.7s ease-in-out infinite;
195
+ transform-origin: 50% 56%;
196
+ }
197
+
198
+ .birdclaw-mark-animated::before {
199
+ animation: birdclaw-load-glow 1.7s ease-in-out infinite;
200
+ }
201
+
144
202
  html.theme-transition {
145
203
  view-transition-name: theme;
146
204
  }
@@ -169,4 +227,9 @@ html.theme-transition::view-transition-new(theme) {
169
227
  html.theme-transition::view-transition-new(theme) {
170
228
  animation: none !important;
171
229
  }
230
+
231
+ .birdclaw-mark-animated,
232
+ .birdclaw-mark-animated::before {
233
+ animation: none !important;
234
+ }
172
235
  }
package/vite.config.ts CHANGED
@@ -4,6 +4,11 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4
4
  import viteReact from "@vitejs/plugin-react";
5
5
  import { defineConfig } from "vite";
6
6
 
7
+ const extraAllowedHosts =
8
+ process.env.BIRDCLAW_ALLOWED_HOSTS?.split(",")
9
+ .map((host) => host.trim())
10
+ .filter(Boolean) ?? [];
11
+
7
12
  const config = defineConfig({
8
13
  plugins: [
9
14
  devtools(),
@@ -18,6 +23,9 @@ const config = defineConfig({
18
23
  resolve: {
19
24
  tsconfigPaths: true,
20
25
  },
26
+ server: {
27
+ allowedHosts: ["clawmac.sheep-coho.ts.net", ...extraAllowedHosts],
28
+ },
21
29
  });
22
30
 
23
31
  export default config;