birdclaw 0.1.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 (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,47 @@
1
+ import { useState } from "react";
2
+ import { getInitials } from "#/lib/present";
3
+ import { avatarChipClass, avatarChipLargeClass, cx } from "#/lib/ui";
4
+
5
+ export function AvatarChip({
6
+ profileId,
7
+ avatarUrl,
8
+ name,
9
+ hue,
10
+ size = "default",
11
+ }: {
12
+ profileId?: string;
13
+ avatarUrl?: string;
14
+ name: string;
15
+ hue: number;
16
+ size?: "default" | "large";
17
+ }) {
18
+ const avatarSrc =
19
+ profileId && avatarUrl
20
+ ? `/api/avatar?profileId=${encodeURIComponent(profileId)}`
21
+ : null;
22
+ const [failedSrc, setFailedSrc] = useState<string | null>(null);
23
+ const showImage = avatarSrc && failedSrc !== avatarSrc;
24
+
25
+ return (
26
+ <span
27
+ className={cx(
28
+ avatarChipClass,
29
+ "avatar-chip",
30
+ size === "large" && cx(avatarChipLargeClass, "avatar-chip-large"),
31
+ )}
32
+ style={{ backgroundColor: `hsl(${hue} 72% 50%)` }}
33
+ >
34
+ {showImage ? (
35
+ <img
36
+ alt={name}
37
+ className="size-full rounded-[inherit] object-cover"
38
+ loading="lazy"
39
+ onError={() => setFailedSrc(avatarSrc)}
40
+ src={avatarSrc}
41
+ />
42
+ ) : (
43
+ getInitials(name)
44
+ )}
45
+ </span>
46
+ );
47
+ }
@@ -0,0 +1,246 @@
1
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
+ import type { DmConversationItem, DmMessageItem } from "#/lib/types";
3
+ import {
4
+ actionButtonClass,
5
+ composerBarClass,
6
+ composerInputClass,
7
+ composerShellClass,
8
+ contextBioClass,
9
+ contextHandleClass,
10
+ contextStatRowClass,
11
+ contextStatsClass,
12
+ contextStatTermClass,
13
+ contextStatValueClass,
14
+ cx,
15
+ dmGridClass,
16
+ dmListClass,
17
+ dmListCopyClass,
18
+ dmListItemActiveClass,
19
+ dmListItemClass,
20
+ dmPreviewTextClass,
21
+ emptyStateClass,
22
+ eyebrowClass,
23
+ identityRowClass,
24
+ messageBubbleClass,
25
+ messageBubbleOutboundClass,
26
+ messageMetaClass,
27
+ messageRowClass,
28
+ messageRowOutboundClass,
29
+ messageStackClass,
30
+ metaStackClass,
31
+ pillAlertClass,
32
+ pillClass,
33
+ pillSoftClass,
34
+ threadBioClass,
35
+ threadDetailColumnClass,
36
+ threadDetailHeaderClass,
37
+ threadHeaderClass,
38
+ threadShellClass,
39
+ threadSubtitleClass,
40
+ threadTitleClass,
41
+ timestampClass,
42
+ } from "#/lib/ui";
43
+ import { AvatarChip } from "./AvatarChip";
44
+
45
+ function MessageBubble({ message }: { message: DmMessageItem }) {
46
+ return (
47
+ <div
48
+ className={cx(
49
+ messageRowClass,
50
+ message.direction === "outbound" && messageRowOutboundClass,
51
+ )}
52
+ >
53
+ <div className={messageMetaClass}>
54
+ <span>{message.sender.displayName}</span>
55
+ <span>{formatShortTimestamp(message.createdAt)}</span>
56
+ </div>
57
+ <div
58
+ className={cx(
59
+ messageBubbleClass,
60
+ message.direction === "outbound" && messageBubbleOutboundClass,
61
+ )}
62
+ >
63
+ {message.text}
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export function DmWorkspace({
70
+ conversations,
71
+ selectedConversation,
72
+ selectedMessages,
73
+ onSelectConversation,
74
+ replyDraft,
75
+ onReplyDraftChange,
76
+ onReplySend,
77
+ }: {
78
+ conversations: DmConversationItem[];
79
+ selectedConversation: DmConversationItem | null;
80
+ selectedMessages: DmMessageItem[];
81
+ onSelectConversation: (conversationId: string) => void;
82
+ replyDraft: string;
83
+ onReplyDraftChange: (value: string) => void;
84
+ onReplySend: (conversationId: string) => void;
85
+ }) {
86
+ const participant = selectedConversation?.participant ?? null;
87
+ const subtitle = selectedConversation
88
+ ? `${selectedConversation.needsReply ? "Reply owed" : "Thread clear"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
89
+ : "No conversation selected";
90
+
91
+ return (
92
+ <section className={dmGridClass}>
93
+ <aside className={dmListClass}>
94
+ {conversations.map((conversation) => {
95
+ const active = conversation.id === selectedConversation?.id;
96
+ return (
97
+ <button
98
+ key={conversation.id}
99
+ className={cx(dmListItemClass, active && dmListItemActiveClass)}
100
+ onClick={() => onSelectConversation(conversation.id)}
101
+ type="button"
102
+ >
103
+ <AvatarChip
104
+ avatarUrl={conversation.participant.avatarUrl}
105
+ hue={conversation.participant.avatarHue}
106
+ name={conversation.participant.displayName}
107
+ profileId={conversation.participant.id}
108
+ />
109
+ <div className={dmListCopyClass}>
110
+ <div className={identityRowClass}>
111
+ <strong>{conversation.participant.displayName}</strong>
112
+ <span>@{conversation.participant.handle}</span>
113
+ </div>
114
+ <p className={dmPreviewTextClass}>
115
+ {conversation.lastMessagePreview}
116
+ </p>
117
+ </div>
118
+ <div className={metaStackClass}>
119
+ <span
120
+ className={cx(
121
+ pillClass,
122
+ conversation.needsReply ? pillAlertClass : pillSoftClass,
123
+ )}
124
+ >
125
+ {conversation.needsReply ? "needs reply" : "clear"}
126
+ </span>
127
+ <span className={cx(pillClass, pillSoftClass)}>
128
+ {conversation.influenceScore} · {conversation.influenceLabel}
129
+ </span>
130
+ <span className={timestampClass}>
131
+ {formatShortTimestamp(conversation.lastMessageAt)}
132
+ </span>
133
+ </div>
134
+ </button>
135
+ );
136
+ })}
137
+ </aside>
138
+
139
+ <div className={threadShellClass}>
140
+ {selectedConversation ? (
141
+ <>
142
+ <header className={threadHeaderClass}>
143
+ <div>
144
+ <p className={eyebrowClass}>direct messages</p>
145
+ <h2 className={threadTitleClass}>
146
+ {selectedConversation.participant.displayName}
147
+ </h2>
148
+ <p className={threadSubtitleClass}>{subtitle}</p>
149
+ <p className={threadBioClass}>
150
+ {selectedConversation.lastMessagePreview}
151
+ </p>
152
+ </div>
153
+ <div className={threadDetailColumnClass}>
154
+ <div className={threadDetailHeaderClass}>
155
+ <AvatarChip
156
+ avatarUrl={participant?.avatarUrl}
157
+ hue={participant?.avatarHue ?? 18}
158
+ name={participant?.displayName ?? "Unknown"}
159
+ profileId={participant?.id ?? undefined}
160
+ size="large"
161
+ />
162
+ <div>
163
+ <strong>{participant?.displayName}</strong>
164
+ <p className={cx("context-handle", contextHandleClass)}>
165
+ @{participant?.handle}
166
+ </p>
167
+ </div>
168
+ </div>
169
+ <p className={cx("context-bio", contextBioClass)}>
170
+ {participant?.bio}
171
+ </p>
172
+ <dl className={contextStatsClass}>
173
+ <div className={contextStatRowClass}>
174
+ <dt className={contextStatTermClass}>Followers</dt>
175
+ <dd className={contextStatValueClass}>
176
+ {formatCompactNumber(participant?.followersCount ?? 0)}
177
+ </dd>
178
+ </div>
179
+ <div className={contextStatRowClass}>
180
+ <dt className={contextStatTermClass}>Influence</dt>
181
+ <dd className={contextStatValueClass}>
182
+ {selectedConversation.influenceScore} ·{" "}
183
+ {selectedConversation.influenceLabel}
184
+ </dd>
185
+ </div>
186
+ <div className={contextStatRowClass}>
187
+ <dt className={contextStatTermClass}>Reply state</dt>
188
+ <dd className={contextStatValueClass}>
189
+ {selectedConversation.needsReply
190
+ ? "Needs reply"
191
+ : "Replied"}
192
+ </dd>
193
+ </div>
194
+ <div className={contextStatRowClass}>
195
+ <dt className={contextStatTermClass}>Account</dt>
196
+ <dd className={contextStatValueClass}>
197
+ {selectedConversation.accountHandle}
198
+ </dd>
199
+ </div>
200
+ </dl>
201
+ <button
202
+ className={actionButtonClass}
203
+ onClick={() => onReplySend(selectedConversation.id)}
204
+ type="button"
205
+ >
206
+ Reply
207
+ </button>
208
+ </div>
209
+ </header>
210
+ <div className={messageStackClass}>
211
+ {selectedMessages.map((message) => (
212
+ <MessageBubble key={message.id} message={message} />
213
+ ))}
214
+ </div>
215
+ <div className={composerShellClass}>
216
+ <textarea
217
+ className={composerInputClass}
218
+ onChange={(event) => onReplyDraftChange(event.target.value)}
219
+ placeholder={`Reply to @${selectedConversation.participant.handle}`}
220
+ rows={4}
221
+ value={replyDraft}
222
+ />
223
+ <div className={composerBarClass}>
224
+ <span className={timestampClass}>
225
+ {selectedConversation.needsReply
226
+ ? "Reply still owed"
227
+ : "Thread clear"}
228
+ </span>
229
+ <button
230
+ className={actionButtonClass}
231
+ disabled={!replyDraft.trim()}
232
+ onClick={() => onReplySend(selectedConversation.id)}
233
+ type="button"
234
+ >
235
+ Send reply
236
+ </button>
237
+ </div>
238
+ </div>
239
+ </>
240
+ ) : (
241
+ <div className={emptyStateClass}>No DM selected.</div>
242
+ )}
243
+ </div>
244
+ </section>
245
+ );
246
+ }
@@ -0,0 +1,44 @@
1
+ import { formatShortTimestamp } from "#/lib/present";
2
+ import type { EmbeddedTweet } from "#/lib/types";
3
+ import {
4
+ embeddedTweetAuthorClass,
5
+ embeddedTweetCardClass,
6
+ embeddedTweetCopyClass,
7
+ embeddedTweetHeaderClass,
8
+ embeddedTweetLabelClass,
9
+ timestampClass,
10
+ } from "#/lib/ui";
11
+ import { ProfilePreview } from "./ProfilePreview";
12
+ import { TweetMediaGrid } from "./TweetMediaGrid";
13
+ import { TweetRichText } from "./TweetRichText";
14
+
15
+ export function EmbeddedTweetCard({
16
+ item,
17
+ label,
18
+ }: {
19
+ item: EmbeddedTweet;
20
+ label: string;
21
+ }) {
22
+ return (
23
+ <section className={embeddedTweetCardClass}>
24
+ <p className={embeddedTweetLabelClass}>{label}</p>
25
+ <header className={embeddedTweetHeaderClass}>
26
+ <ProfilePreview profile={item.author}>
27
+ <span className={embeddedTweetAuthorClass}>
28
+ <strong>{item.author.displayName}</strong>
29
+ <span>@{item.author.handle}</span>
30
+ </span>
31
+ </ProfilePreview>
32
+ <span className={timestampClass}>
33
+ {formatShortTimestamp(item.createdAt)}
34
+ </span>
35
+ </header>
36
+ <TweetRichText
37
+ className={embeddedTweetCopyClass}
38
+ entities={item.entities}
39
+ text={item.text}
40
+ />
41
+ <TweetMediaGrid items={item.media} />
42
+ </section>
43
+ );
44
+ }
@@ -0,0 +1,136 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
3
+ import type { InboxItem } from "#/lib/types";
4
+ import {
5
+ actionButtonClass,
6
+ actionRowClass,
7
+ bodyCopyClass,
8
+ cardFooterClass,
9
+ cardHeaderClass,
10
+ composerBarClass,
11
+ composerInputClass,
12
+ composerShellClass,
13
+ contentCardClass,
14
+ cx,
15
+ eyebrowClass,
16
+ identityBlockClass,
17
+ identityRowClass,
18
+ inboxAnalysisClass,
19
+ inboxTitleClass,
20
+ metaStackClass,
21
+ metricRowClass,
22
+ mutedDotClass,
23
+ navLinkClass,
24
+ pillAlertClass,
25
+ pillClass,
26
+ pillSoftClass,
27
+ timestampClass,
28
+ } from "#/lib/ui";
29
+ import { AvatarChip } from "./AvatarChip";
30
+
31
+ export function InboxCard({
32
+ item,
33
+ isReplying,
34
+ replyDraft,
35
+ onReplyChange,
36
+ onReplyToggle,
37
+ onReplySend,
38
+ }: {
39
+ item: InboxItem;
40
+ isReplying: boolean;
41
+ replyDraft: string;
42
+ onReplyChange: (value: string) => void;
43
+ onReplyToggle: () => void;
44
+ onReplySend: () => void;
45
+ }) {
46
+ return (
47
+ <article className={cx(contentCardClass, "inbox-card")}>
48
+ <div className={cardHeaderClass}>
49
+ <div className={identityBlockClass}>
50
+ <AvatarChip
51
+ avatarUrl={item.participant.avatarUrl}
52
+ hue={item.participant.avatarHue}
53
+ name={item.participant.displayName}
54
+ profileId={item.participant.id}
55
+ />
56
+ <div>
57
+ <div className={identityRowClass}>
58
+ <strong>{item.participant.displayName}</strong>
59
+ <span>@{item.participant.handle}</span>
60
+ <span className={mutedDotClass} />
61
+ <span>
62
+ {formatCompactNumber(item.participant.followersCount)} followers
63
+ </span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ <div className={metaStackClass}>
68
+ <span className={cx(pillClass, pillSoftClass)}>
69
+ {item.entityKind}
70
+ </span>
71
+ <span className={cx(pillClass, pillAlertClass)}>
72
+ score {item.score}
73
+ </span>
74
+ <span className={timestampClass}>
75
+ {formatShortTimestamp(item.createdAt)}
76
+ </span>
77
+ </div>
78
+ </div>
79
+ <p className={eyebrowClass}>ai triage</p>
80
+ <h3 className={inboxTitleClass}>{item.title}</h3>
81
+ <p className={bodyCopyClass}>{item.text}</p>
82
+ <div className={inboxAnalysisClass}>
83
+ <strong>{item.summary}</strong>
84
+ <p>{item.reasoning}</p>
85
+ </div>
86
+ <div className={cardFooterClass}>
87
+ <div className={metricRowClass}>
88
+ <span>{item.source}</span>
89
+ <span>influence {item.influenceScore}</span>
90
+ <span>{item.needsReply ? "needs reply" : "resolved"}</span>
91
+ </div>
92
+ <div className={actionRowClass}>
93
+ <button
94
+ className={navLinkClass}
95
+ onClick={onReplyToggle}
96
+ type="button"
97
+ >
98
+ {isReplying ? "Close reply" : "Reply"}
99
+ </button>
100
+ <Link
101
+ className={actionButtonClass}
102
+ to={item.entityKind === "dm" ? "/dms" : "/mentions"}
103
+ >
104
+ Open
105
+ </Link>
106
+ </div>
107
+ </div>
108
+ {isReplying ? (
109
+ <div className={composerShellClass}>
110
+ <textarea
111
+ className={composerInputClass}
112
+ onChange={(event) => onReplyChange(event.target.value)}
113
+ placeholder={
114
+ item.entityKind === "dm"
115
+ ? `Reply to @${item.participant.handle}`
116
+ : `Reply to mention from @${item.participant.handle}`
117
+ }
118
+ rows={4}
119
+ value={replyDraft}
120
+ />
121
+ <div className={composerBarClass}>
122
+ <span className={timestampClass}>Send from inbox</span>
123
+ <button
124
+ className={actionButtonClass}
125
+ disabled={!replyDraft.trim()}
126
+ onClick={onReplySend}
127
+ type="button"
128
+ >
129
+ Send
130
+ </button>
131
+ </div>
132
+ </div>
133
+ ) : null}
134
+ </article>
135
+ );
136
+ }
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react";
2
+ import { formatCompactNumber } from "#/lib/present";
3
+ import type { ProfileRecord } from "#/lib/types";
4
+ import {
5
+ cx,
6
+ profilePreviewBioClass,
7
+ profilePreviewCardClass,
8
+ profilePreviewClass,
9
+ profilePreviewHandleClass,
10
+ profilePreviewHeaderClass,
11
+ profilePreviewMetaClass,
12
+ profilePreviewTriggerClass,
13
+ } from "#/lib/ui";
14
+ import { AvatarChip } from "./AvatarChip";
15
+
16
+ export function ProfilePreview({
17
+ profile,
18
+ children,
19
+ className = "",
20
+ }: {
21
+ profile: ProfileRecord;
22
+ children: ReactNode;
23
+ className?: string;
24
+ }) {
25
+ return (
26
+ <span className={cx(profilePreviewClass, "group", className)}>
27
+ <a
28
+ className={profilePreviewTriggerClass}
29
+ href={`https://x.com/${profile.handle}`}
30
+ rel="noreferrer"
31
+ target="_blank"
32
+ >
33
+ {children}
34
+ </a>
35
+ <span className={profilePreviewCardClass}>
36
+ <span className={profilePreviewHeaderClass}>
37
+ <AvatarChip
38
+ avatarUrl={profile.avatarUrl}
39
+ hue={profile.avatarHue}
40
+ name={profile.displayName}
41
+ profileId={profile.id}
42
+ />
43
+ <span>
44
+ <strong>{profile.displayName}</strong>
45
+ <span className={profilePreviewHandleClass}>@{profile.handle}</span>
46
+ </span>
47
+ </span>
48
+ <span className={profilePreviewBioClass}>{profile.bio}</span>
49
+ <span className={profilePreviewMetaClass}>
50
+ {formatCompactNumber(profile.followersCount)} followers
51
+ </span>
52
+ </span>
53
+ </span>
54
+ );
55
+ }
@@ -0,0 +1,124 @@
1
+ import { Monitor, Moon, Sun } from "lucide-react";
2
+ import type { CSSProperties, MouseEvent } from "react";
3
+ import { useMemo } from "react";
4
+ import { type ThemeValue, useTheme } from "#/lib/theme";
5
+ import {
6
+ startThemeTransition,
7
+ type ThemeTransitionContext,
8
+ } from "#/lib/theme-transition";
9
+ import { cx } from "#/lib/ui";
10
+
11
+ const THEME_OPTIONS = [
12
+ { key: "system", icon: Monitor, label: "System default" },
13
+ { key: "light", icon: Sun, label: "Light theme" },
14
+ { key: "dark", icon: Moon, label: "Dark theme" },
15
+ ] as const satisfies Array<{
16
+ key: ThemeValue;
17
+ icon: typeof Sun;
18
+ label: string;
19
+ }>;
20
+
21
+ const ACTIVE_ITEM_WIDTH_PX = 30;
22
+ const GAP_PX = 6;
23
+ const CONTAINER_PADDING_PX = 6;
24
+ const INDICATOR_SIZE_PX = 30;
25
+ const INDICATOR_OVERHANG_PX = (INDICATOR_SIZE_PX - ACTIVE_ITEM_WIDTH_PX) / 2;
26
+ const INDICATOR_BASE_OFFSET_PX = CONTAINER_PADDING_PX - INDICATOR_OVERHANG_PX;
27
+
28
+ function toPx(value: number) {
29
+ return `${String(value)}px`;
30
+ }
31
+
32
+ export function ThemeSlider() {
33
+ const { isReady, theme, resolvedTheme, setTheme } = useTheme();
34
+
35
+ const activeIndex = useMemo(() => {
36
+ const index = THEME_OPTIONS.findIndex((option) => option.key === theme);
37
+ return index === -1 ? 0 : index;
38
+ }, [theme]);
39
+
40
+ const indicatorOffset = activeIndex * (ACTIVE_ITEM_WIDTH_PX + GAP_PX);
41
+ const indicatorStyle = useMemo<CSSProperties>(
42
+ () => ({
43
+ left: toPx(INDICATOR_BASE_OFFSET_PX),
44
+ transform: `translate(${toPx(indicatorOffset)}, -50%)`,
45
+ }),
46
+ [indicatorOffset],
47
+ );
48
+ const sliderStyle = useMemo<CSSProperties>(
49
+ () => ({
50
+ gridTemplateColumns: `repeat(${String(THEME_OPTIONS.length)}, ${toPx(ACTIVE_ITEM_WIDTH_PX)})`,
51
+ columnGap: toPx(GAP_PX),
52
+ padding: `0 ${toPx(CONTAINER_PADDING_PX)}`,
53
+ width: toPx(
54
+ THEME_OPTIONS.length * ACTIVE_ITEM_WIDTH_PX +
55
+ (THEME_OPTIONS.length - 1) * GAP_PX +
56
+ CONTAINER_PADDING_PX * 2,
57
+ ),
58
+ }),
59
+ [],
60
+ );
61
+
62
+ return (
63
+ <fieldset
64
+ className="theme-slider-shell m-0 border-0 p-0"
65
+ aria-label="Theme selector"
66
+ >
67
+ <div
68
+ className="theme-slider relative grid h-[42px] place-items-center overflow-hidden rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)] shadow-[inset_0_1px_0_color-mix(in_srgb,white_40%,transparent)] transition-[background,border-color] duration-180"
69
+ style={sliderStyle}
70
+ >
71
+ <div
72
+ className={cx(
73
+ "theme-slider-indicator pointer-events-none absolute top-1/2 z-0 size-[30px] rounded-full border border-[color:color-mix(in_srgb,var(--line-strong)_85%,transparent)] bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.6),transparent_58%),var(--panel-strong)] shadow-[0_10px_24px_var(--shadow),inset_0_1px_0_rgba(255,255,255,0.35)] transition-[transform,background,border-color,box-shadow] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]",
74
+ resolvedTheme === "dark" &&
75
+ "theme-slider-indicator-dark shadow-[0_10px_24px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)]",
76
+ )}
77
+ style={indicatorStyle}
78
+ />
79
+ {THEME_OPTIONS.map((option, index) => {
80
+ const Icon = option.icon;
81
+ const isActive = index === activeIndex;
82
+
83
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
84
+ if (isActive) return;
85
+
86
+ const context: ThemeTransitionContext = {
87
+ element: event.currentTarget,
88
+ pointerClientX: event.clientX,
89
+ pointerClientY: event.clientY,
90
+ };
91
+
92
+ startThemeTransition({
93
+ nextTheme: option.key,
94
+ currentTheme: theme,
95
+ setTheme,
96
+ context,
97
+ });
98
+ };
99
+
100
+ return (
101
+ <button
102
+ key={option.key}
103
+ type="button"
104
+ className={cx(
105
+ "theme-slider-button relative z-[1] inline-flex size-[30px] items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[color,transform] duration-160 hover:-translate-y-px hover:text-[var(--ink)] disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
106
+ isActive && "theme-slider-button-active text-[var(--ink)]",
107
+ )}
108
+ onClick={handleClick}
109
+ aria-label={option.label}
110
+ aria-pressed={isActive}
111
+ data-testid={`theme-${option.key}`}
112
+ disabled={!isReady}
113
+ >
114
+ <Icon
115
+ className="theme-slider-icon size-[15px]"
116
+ strokeWidth={1.8}
117
+ />
118
+ </button>
119
+ );
120
+ })}
121
+ </div>
122
+ </fieldset>
123
+ );
124
+ }